Nightly downloads #5
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: Nightly downloads | |
| # Builds Tauri desktop bundles + Android APK every day, uploads them to the | |
| # Cloudflare R2 bucket behind binaries.betaflight.com, regenerates the static | |
| # downloads index, and deploys it to the betaflight-downloads Cloudflare Pages | |
| # project (downloads.betaflight.com). | |
| # | |
| # Required repository configuration: | |
| # Variables: | |
| # CLOUDFLARE_R2_BUCKET — R2 bucket name (e.g. betaflight-downloads) | |
| # CLOUDFLARE_R2_ENDPOINT — https://<accountid>.r2.cloudflarestorage.com | |
| # CLOUDFLARE_R2_PUBLIC_BASE_URL — https://binaries.betaflight.com | |
| # CLOUDFLARE_DOWNLOADS_PROJECT_NAME — Cloudflare Pages project (e.g. betaflight-downloads) | |
| # WEB_APP_MASTER_URL (optional) — Master web-app URL (defaults to main project preview) | |
| # WEB_APP_RELEASE_URL (optional) — Release web-app URL (defaults to https://app.betaflight.com) | |
| # Secrets: | |
| # CLOUDFLARE_R2_ACCESS_KEY_ID | |
| # CLOUDFLARE_R2_SECRET_ACCESS_KEY | |
| # CLOUDFLARE_API_TOKEN — already provisioned for the existing PWA deploy | |
| # CLOUDFLARE_ACCOUNT_ID — already provisioned | |
| # RELEASE_KEYSTORE / *_PASSWORD / *_ALIAS / *_ALIAS_PASSWORD — already provisioned for APK signing | |
| # | |
| # R2 lifecycle: configure a 30-day expiry rule on prefix "nightly/" against the bucket | |
| # (one-time, in the Cloudflare dashboard or via the R2 API). | |
| on: | |
| schedule: | |
| - cron: '0 3 * * *' | |
| workflow_dispatch: | |
| concurrency: | |
| group: nightly-downloads | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read | |
| jobs: | |
| prepare: | |
| name: Prepare nightly metadata | |
| runs-on: ubuntu-latest | |
| outputs: | |
| nightly_date: ${{ steps.meta.outputs.nightly_date }} | |
| nightly_prefix: ${{ steps.meta.outputs.nightly_prefix }} | |
| commit_sha: ${{ steps.meta.outputs.commit_sha }} | |
| version: ${{ steps.meta.outputs.version }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v5 | |
| - name: Compute metadata | |
| id: meta | |
| run: | | |
| NIGHTLY_DATE=$(date -u +%Y-%m-%d) | |
| COMMIT_SHA=$(git rev-parse HEAD) | |
| VERSION=$(jq -r '.version' package.json) | |
| { | |
| echo "nightly_date=${NIGHTLY_DATE}" | |
| echo "nightly_prefix=nightly/${NIGHTLY_DATE}" | |
| echo "commit_sha=${COMMIT_SHA}" | |
| echo "version=${VERSION}" | |
| } >> "$GITHUB_OUTPUT" | |
| desktop: | |
| name: Tauri desktop (${{ matrix.label }}) | |
| needs: prepare | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - os: ubuntu-latest | |
| label: linux | |
| target: '' | |
| - os: macos-latest | |
| label: macos-arm64 | |
| target: aarch64-apple-darwin | |
| - os: macos-15-intel | |
| label: macos-x64 | |
| target: x86_64-apple-darwin | |
| - os: windows-latest | |
| label: windows | |
| target: '' | |
| runs-on: ${{ matrix.os }} | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v5 | |
| - name: Setup Node | |
| uses: actions/setup-node@v5 | |
| with: | |
| node-version-file: .nvmrc | |
| cache: npm | |
| - name: Install Tauri Linux prereqs | |
| if: runner.os == 'Linux' | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y \ | |
| libwebkit2gtk-4.1-dev \ | |
| libjavascriptcoregtk-4.1-dev \ | |
| libsoup-3.0-dev \ | |
| librsvg2-dev \ | |
| libssl-dev \ | |
| libudev-dev \ | |
| rpm | |
| - name: Setup Rust toolchain | |
| uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 | |
| with: | |
| targets: ${{ matrix.target }} | |
| - name: Cache Cargo registry and target | |
| uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 | |
| with: | |
| workspaces: src-tauri | |
| key: ${{ matrix.label }} | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Build Tauri bundle | |
| shell: bash | |
| run: | | |
| if [ -n "${{ matrix.target }}" ]; then | |
| npm run tauri:build -- --target ${{ matrix.target }} | |
| else | |
| npm run tauri:build | |
| fi | |
| - name: Stage desktop bundles | |
| shell: bash | |
| env: | |
| PKG_VERSION: ${{ needs.prepare.outputs.version }} | |
| COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }} | |
| run: | | |
| mkdir -p staging | |
| shopt -s nullglob | |
| for path in \ | |
| src-tauri/target/release/bundle/deb/*.deb \ | |
| src-tauri/target/release/bundle/rpm/*.rpm \ | |
| src-tauri/target/release/bundle/appimage/*.AppImage \ | |
| src-tauri/target/release/bundle/dmg/*.dmg \ | |
| src-tauri/target/release/bundle/nsis/*.exe \ | |
| src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb \ | |
| src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm \ | |
| src-tauri/target/${{ matrix.target }}/release/bundle/appimage/*.AppImage \ | |
| src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg \ | |
| src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*.exe | |
| do | |
| cp "$path" staging/ | |
| done | |
| if ! compgen -G 'staging/*' > /dev/null; then | |
| echo "No desktop bundles produced for ${{ matrix.label }}" >&2 | |
| exit 1 | |
| fi | |
| STRIPPED="${PKG_VERSION%%-*}" | |
| SHA_SHORT="${COMMIT_SHA:0:8}" | |
| NIGHTLY_VERSION="${PKG_VERSION}-${SHA_SHORT}" | |
| for f in staging/*; do | |
| base=$(basename "$f") | |
| new=${base//Betaflight App/betaflight-app} | |
| new=${new/${STRIPPED}/${NIGHTLY_VERSION}} | |
| if [[ "$new" != "$base" ]]; then | |
| mv -- "$f" "staging/${new}" | |
| fi | |
| done | |
| ls -la staging | |
| - name: Sync desktop bundles to R2 | |
| shell: bash | |
| env: | |
| AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }} | |
| AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }} | |
| AWS_DEFAULT_REGION: auto | |
| R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }} | |
| R2_ENDPOINT: ${{ vars.CLOUDFLARE_R2_ENDPOINT }} | |
| PREFIX: ${{ needs.prepare.outputs.nightly_prefix }}/desktop | |
| run: | | |
| aws s3 sync staging/ "s3://${R2_BUCKET}/${PREFIX}/" --endpoint-url "${R2_ENDPOINT}" --no-progress | |
| - name: Emit per-platform manifest fragment | |
| shell: bash | |
| env: | |
| PUBLIC_BASE: ${{ vars.CLOUDFLARE_R2_PUBLIC_BASE_URL }} | |
| PREFIX: ${{ needs.prepare.outputs.nightly_prefix }}/desktop | |
| PLATFORM: ${{ matrix.label }} | |
| run: | | |
| mkdir -p manifest-fragments | |
| node <<'NODE' | |
| const fs = require('node:fs'); | |
| const path = require('node:path'); | |
| const base = process.env.PUBLIC_BASE.replace(/\/$/, ''); | |
| const prefix = process.env.PREFIX; | |
| const platform = process.env.PLATFORM; | |
| const files = fs.readdirSync('staging'); | |
| const entries = files.map((file) => { | |
| const stat = fs.statSync(path.join('staging', file)); | |
| let group; | |
| let label; | |
| if (file.endsWith('.exe')) { | |
| group = 'windows'; | |
| label = 'Windows installer (x64)'; | |
| } else if (file.endsWith('.dmg')) { | |
| group = 'macos'; | |
| label = platform === 'macos-arm64' ? 'macOS (Apple Silicon)' : 'macOS (Intel)'; | |
| } else if (file.endsWith('.deb')) { | |
| group = 'linux'; | |
| label = 'Linux (.deb)'; | |
| } else if (file.endsWith('.rpm')) { | |
| group = 'linux'; | |
| label = 'Linux (.rpm)'; | |
| } else if (file.endsWith('.AppImage')) { | |
| group = 'linux'; | |
| label = 'Linux (AppImage)'; | |
| } else { | |
| return null; | |
| } | |
| return { | |
| group, | |
| entry: { | |
| label, | |
| url: `${base}/${prefix}/${encodeURIComponent(file)}`, | |
| size: stat.size, | |
| }, | |
| }; | |
| }).filter(Boolean); | |
| fs.writeFileSync(`manifest-fragments/${platform}.json`, JSON.stringify(entries)); | |
| NODE | |
| - name: Upload manifest fragment | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: manifest-${{ matrix.label }} | |
| path: manifest-fragments/${{ matrix.label }}.json | |
| android: | |
| name: Android APK | |
| needs: prepare | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v5 | |
| with: | |
| persist-credentials: false | |
| - name: Setup Node | |
| uses: actions/setup-node@v5 | |
| with: | |
| node-version-file: .nvmrc | |
| - run: npm install yarn -g | |
| - run: yarn install | |
| - run: yarn build | |
| - run: node capacitor.config.generator.mjs | |
| - run: npx cap sync android | |
| - name: Setup Java | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: temurin | |
| java-version: '21' | |
| cache: gradle | |
| - name: Build and sign APK | |
| env: | |
| RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE }} | |
| RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} | |
| RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} | |
| RELEASE_KEY_ALIAS_PASSWORD: ${{ secrets.RELEASE_KEY_ALIAS_PASSWORD }} | |
| run: | | |
| : "${RELEASE_KEYSTORE_BASE64:?Set the RELEASE_KEYSTORE secret with your base64-encoded keystore}" | |
| : "${RELEASE_KEYSTORE_PASSWORD:?Set the RELEASE_KEYSTORE_PASSWORD secret with your keystore password}" | |
| : "${RELEASE_KEY_ALIAS:?Set the RELEASE_KEY_ALIAS secret with your key alias}" | |
| : "${RELEASE_KEY_ALIAS_PASSWORD:?Set the RELEASE_KEY_ALIAS_PASSWORD secret with your key alias password}" | |
| mkdir -p android/keystore | |
| echo "$RELEASE_KEYSTORE_BASE64" | base64 --decode > android/keystore/release.jks | |
| pushd android >/dev/null | |
| ./gradlew clean | |
| ./gradlew assembleRelease \ | |
| -Pandroid.injected.signing.store.file="$PWD/keystore/release.jks" \ | |
| -Pandroid.injected.signing.store.password="$RELEASE_KEYSTORE_PASSWORD" \ | |
| -Pandroid.injected.signing.key.alias="$RELEASE_KEY_ALIAS" \ | |
| -Pandroid.injected.signing.key.password="$RELEASE_KEY_ALIAS_PASSWORD" | |
| popd >/dev/null | |
| - name: Stage APK | |
| env: | |
| VERSION: ${{ needs.prepare.outputs.version }} | |
| COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }} | |
| run: | | |
| mkdir -p staging | |
| SHA_SHORT=$(echo "$COMMIT_SHA" | head -c 8) | |
| APK_NAME="betaflight-app-${VERSION}-${SHA_SHORT}.apk" | |
| cp android/app/build/outputs/apk/release/app-release.apk "staging/${APK_NAME}" | |
| echo "APK_NAME=${APK_NAME}" >> "$GITHUB_ENV" | |
| - name: Sync APK to R2 | |
| env: | |
| AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }} | |
| AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }} | |
| AWS_DEFAULT_REGION: auto | |
| R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }} | |
| R2_ENDPOINT: ${{ vars.CLOUDFLARE_R2_ENDPOINT }} | |
| PREFIX: ${{ needs.prepare.outputs.nightly_prefix }}/android | |
| run: | | |
| aws s3 sync staging/ "s3://${R2_BUCKET}/${PREFIX}/" --endpoint-url "${R2_ENDPOINT}" --no-progress | |
| - name: Emit Android manifest fragment | |
| env: | |
| PUBLIC_BASE: ${{ vars.CLOUDFLARE_R2_PUBLIC_BASE_URL }} | |
| PREFIX: ${{ needs.prepare.outputs.nightly_prefix }}/android | |
| run: | | |
| mkdir -p manifest-fragments | |
| BASE="${PUBLIC_BASE%/}" | |
| SIZE=$(stat -c %s "staging/${APK_NAME}") | |
| jq -n --arg label "Android APK" --arg url "${BASE}/${PREFIX}/${APK_NAME}" --argjson size "$SIZE" \ | |
| '{label: $label, url: $url, size: $size}' > manifest-fragments/android.json | |
| - name: Upload manifest fragment | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: manifest-android | |
| path: manifest-fragments/android.json | |
| publish: | |
| name: Publish downloads index | |
| if: github.ref == 'refs/heads/master' | |
| needs: [prepare, desktop, android] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| deployments: write | |
| environment: | |
| name: nightly-downloads | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v5 | |
| - name: Setup Node | |
| uses: actions/setup-node@v5 | |
| with: | |
| node-version-file: .nvmrc | |
| - name: Download manifest fragments | |
| uses: actions/download-artifact@v5 | |
| with: | |
| path: manifest-fragments | |
| pattern: manifest-* | |
| merge-multiple: true | |
| - name: Build manifest | |
| env: | |
| NIGHTLY_DATE: ${{ needs.prepare.outputs.nightly_date }} | |
| COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }} | |
| VERSION: ${{ needs.prepare.outputs.version }} | |
| run: | | |
| node <<'NODE' | |
| const fs = require('node:fs'); | |
| const path = require('node:path'); | |
| const fragmentsDir = 'manifest-fragments'; | |
| const desktop = { windows: [], macos: [], linux: [] }; | |
| let android = null; | |
| for (const file of fs.readdirSync(fragmentsDir)) { | |
| const data = JSON.parse(fs.readFileSync(path.join(fragmentsDir, file), 'utf8')); | |
| if (file === 'android.json') { | |
| android = data; | |
| continue; | |
| } | |
| for (const item of data) { | |
| desktop[item.group]?.push(item.entry); | |
| } | |
| } | |
| const manifest = { | |
| generatedAt: new Date().toISOString(), | |
| nightly: { | |
| date: process.env.NIGHTLY_DATE, | |
| commit: process.env.COMMIT_SHA, | |
| version: process.env.VERSION, | |
| desktop, | |
| android, | |
| }, | |
| }; | |
| fs.writeFileSync('manifest.json', JSON.stringify(manifest, null, 2)); | |
| NODE | |
| cat manifest.json | |
| - name: Fetch GitHub releases | |
| shell: bash | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -o pipefail | |
| gh api --paginate --slurp "repos/${{ github.repository }}/releases?per_page=100" | jq 'add' > releases.json | |
| jq -e 'type == "array" and (length == 0 or all(.[]; has("tag_name") and has("html_url")))' releases.json > /dev/null | |
| - name: Generate index | |
| env: | |
| MASTER_URL: ${{ vars.WEB_APP_MASTER_URL }} | |
| RELEASE_URL: ${{ vars.WEB_APP_RELEASE_URL }} | |
| run: | | |
| ARGS=(--manifest manifest.json --releases releases.json --output dist-downloads) | |
| if [ -n "${MASTER_URL}" ]; then | |
| ARGS+=(--master-url "${MASTER_URL}") | |
| fi | |
| if [ -n "${RELEASE_URL}" ]; then | |
| ARGS+=(--release-url "${RELEASE_URL}") | |
| fi | |
| node scripts/generate-downloads-index.mjs "${ARGS[@]}" | |
| - name: Deploy to Cloudflare Pages | |
| uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1 | |
| with: | |
| apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| command: pages deploy dist-downloads --project-name=${{ vars.CLOUDFLARE_DOWNLOADS_PROJECT_NAME }} --branch=master --commit-dirty=true |