# .github/workflows/release.yml # Multi-Platform Automated Release Pipeline for D3RO Voice Desktop & Admin name: Release & Packaging Pipeline on: push: tags: - 'v*.*.*' workflow_dispatch: inputs: version: description: 'Release version (e.g. 1.0.0)' required: true default: '1.1.0' permissions: contents: write packages: write jobs: release-preflight: name: Release Preflight runs-on: ubuntu-latest steps: - name: Checkout Code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Node.js 24 uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' cache: 'npm' - name: Setup .NET 10 uses: actions/setup-dotnet@v5 with: dotnet-version: '10.0.302' - name: Setup Deno 2.8.1 uses: denoland/setup-deno@v2 with: deno-version: v2.8.1 - name: Install JavaScript Dependencies run: | npm ci npm --prefix apps/mobile-rn ci --workspaces=false - name: Verify Source, Security, Tests, and Play Assets run: | npm run version:check if [[ "$GITHUB_REF" == refs/tags/* ]]; then node scripts/ci/sync-version.mjs --check --tag "$GITHUB_REF_NAME" fi npm run release:metadata:test npm run security:secrets:test npm run security:secrets npm run release:mobile:boundary:test npm run release:mobile:config:test npm run release:mobile:build-config:test npm run release:play:assets npm run lint npm run typecheck npm test npm --prefix apps/mobile-rn run lint npm --prefix apps/mobile-rn run typecheck npm --prefix apps/mobile-rn run test:ci - name: Check and Test Every Supabase Edge Function shell: bash run: | set -euo pipefail for entrypoint in server/supabase/functions/*/index.ts; do deno check --config server/supabase/functions/deno.json "$entrypoint" done deno test --config server/supabase/functions/deno.json --allow-read --allow-env server/supabase/functions - name: Test .NET API Authorization and Gateway Boundaries run: | dotnet restore apps/api-server.Tests/D3ROVoice.Api.Tests.csproj dotnet test apps/api-server.Tests/D3ROVoice.Api.Tests.csproj --configuration Release --no-restore -p:StaticWebAssetsEnabled=false # ────────────────────────────────────────────────────────────────── # 1. Package Windows Installer (.exe & .blockmap & latest.yml) # ────────────────────────────────────────────────────────────────── package-windows: name: Package Windows Desktop App needs: release-preflight runs-on: windows-latest steps: - name: Checkout Code uses: actions/checkout@v4 - name: Setup Node.js 24 uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' cache: 'npm' - name: Install Dependencies run: npm ci - name: Build All Workspaces run: | npm run version:check npm run typecheck npm run build --workspace=@d3ro/desktop - name: Package with Electron Builder (NSIS x64) shell: pwsh run: | $ErrorActionPreference = 'Stop' if ([string]::IsNullOrWhiteSpace($env:CSC_LINK)) { throw 'WIN_CSC_LINK must contain the production code-signing PFX path, URL, or base64 payload.' } if ([string]::IsNullOrWhiteSpace($env:CSC_KEY_PASSWORD)) { throw 'WIN_CSC_KEY_PASSWORD is required.' } if ([string]::IsNullOrWhiteSpace($env:WIN_CSC_EXPECTED_SIGNER_SUBJECT)) { throw 'WIN_CSC_EXPECTED_SIGNER_SUBJECT is required.' } if ($env:WIN_CSC_EXPECTED_SIGNER_SUBJECT -match '(?i)Everything2EverythingDev') { throw 'The local Everything2EverythingDev certificate is not a production signing identity.' } $releaseVersion = node -p "require('./release/product-version.json').version" Push-Location apps/desktop try { npx electron-builder --win --x64 --config electron-builder.yml if ($LASTEXITCODE -ne 0) { throw "electron-builder failed with exit code $LASTEXITCODE." } } finally { Pop-Location } & scripts/ci/verify-windows-release-artifact.ps1 ` -ExpectedVersion $releaseVersion ` -ExpectedSignerSubject $env:WIN_CSC_EXPECTED_SIGNER_SUBJECT ` -ReleaseDirectory "apps/desktop/release/$releaseVersion" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_LINK: ${{ secrets.WIN_CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} WIN_CSC_EXPECTED_SIGNER_SUBJECT: ${{ secrets.WIN_CSC_EXPECTED_SIGNER_SUBJECT }} - name: Upload Windows Build Artifacts uses: actions/upload-artifact@v4 with: name: windows-release-assets path: | apps/desktop/release/*/*.exe apps/desktop/release/*/*.blockmap apps/desktop/release/*/latest.yml # ────────────────────────────────────────────────────────────────── # 2. Package macOS Desktop App (.dmg & .zip & latest-mac.yml) # ────────────────────────────────────────────────────────────────── package-macos: name: Package macOS Desktop App needs: release-preflight runs-on: macos-latest steps: - name: Checkout Code uses: actions/checkout@v4 - name: Setup Node.js 24 uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' cache: 'npm' - name: Install Dependencies run: npm ci - name: Build All Workspaces run: | npm run version:check npm run typecheck npm run build --workspace=@d3ro/desktop - name: Package with Electron Builder (DMG & ZIP arm64) run: | cd apps/desktop npx electron-builder --mac --arm64 --config electron-builder.yml env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_LINK: ${{ secrets.MAC_CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - name: Upload macOS Build Artifacts uses: actions/upload-artifact@v4 with: name: macos-release-assets path: | apps/desktop/release/*/*.dmg apps/desktop/release/*/*.zip apps/desktop/release/*/*.blockmap apps/desktop/release/*/latest-mac.yml # ────────────────────────────────────────────────────────────────── # 3. Package signed Android APK/AAB (arm64, production-only config) # ────────────────────────────────────────────────────────────────── package-android: name: Package Android Mobile App needs: release-preflight runs-on: ubuntu-latest environment: mobile-production-release steps: - name: Checkout Code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Establish Trusted Mobile Release Identity id: android-version shell: bash env: DISPATCH_VERSION: ${{ inputs.version }} run: | set -euo pipefail git fetch --no-tags origin main if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then test "$GITHUB_REF" = "refs/heads/main" test "$GITHUB_SHA" = "$(git rev-parse origin/main)" VERSION_NAME="$DISPATCH_VERSION" RELEASE_TAG="v$VERSION_NAME" else [[ "$GITHUB_REF" == refs/tags/v* ]] VERSION_NAME="${GITHUB_REF_NAME#v}" RELEASE_TAG="$GITHUB_REF_NAME" git merge-base --is-ancestor "$GITHUB_SHA" origin/main fi SSOT_VERSION="$(node -p "require('./release/product-version.json').version")" VERSION_CODE="$(node -p "require('./release/product-version.json').androidVersionCode")" [[ "$VERSION_NAME" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] test "$VERSION_NAME" = "$SSOT_VERSION" test "$RELEASE_TAG" = "v$VERSION_NAME" [[ "$VERSION_CODE" =~ ^[1-9][0-9]{0,9}$ ]] test "$VERSION_CODE" -le 2100000000 printf 'name=%s\n' "$VERSION_NAME" >> "$GITHUB_OUTPUT" printf 'code=%s\n' "$VERSION_CODE" >> "$GITHUB_OUTPUT" printf 'tag=%s\n' "$RELEASE_TAG" >> "$GITHUB_OUTPUT" - name: Verify Immutable Checkout Identity shell: bash run: | set -euo pipefail test "$GITHUB_SHA" = "$(git rev-parse HEAD)" test -z "$(git status --porcelain --untracked-files=all)" git rev-parse "${GITHUB_SHA}^{tree}" - name: Setup Node.js 24 uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' cache: 'npm' - name: Verify Mobile Release Boundary Source Contract run: node scripts/ci/verify-mobile-release-boundary.mjs --self-test - name: Verify Mobile Release Configuration Contract run: npm run release:mobile:config:test - name: Verify Mobile Build Configuration Contract run: npm run release:mobile:build-config:test - name: Verify Play Store Asset Contract run: npm run release:play:assets - name: Require Restricted AAB Handoff Visibility shell: bash env: REPOSITORY_VISIBILITY: ${{ github.event.repository.visibility }} run: | set -euo pipefail test "$REPOSITORY_VISIBILITY" = "private" - name: Setup JDK 17 uses: actions/setup-java@v5 with: distribution: temurin java-version: '17' - name: Setup Android SDK uses: android-actions/setup-android@v4 - name: Install Pinned Official Bundletool shell: bash run: | set -euo pipefail curl --fail --silent --show-error --location \ --output "$RUNNER_TEMP/bundletool-all-1.18.3.jar" \ https://github.com/google/bundletool/releases/download/1.18.3/bundletool-all-1.18.3.jar printf '%s %s\n' \ a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29 \ "$RUNNER_TEMP/bundletool-all-1.18.3.jar" | sha256sum --check --strict - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 with: cache-provider: basic - name: Install Dependencies run: | npm ci npm --prefix apps/mobile-rn ci --workspaces=false - name: Materialize Release-only Configuration shell: bash env: ANDROID_RELEASE_KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }} ANDROID_GOOGLE_SERVICES_JSON_B64: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON_B64 }} run: | set -euo pipefail umask 077 test -n "$ANDROID_RELEASE_KEYSTORE_B64" test -n "$ANDROID_GOOGLE_SERVICES_JSON_B64" printf '%s' "$ANDROID_RELEASE_KEYSTORE_B64" | base64 --decode > apps/mobile-rn/android/app/release.keystore printf '%s' "$ANDROID_GOOGLE_SERVICES_JSON_B64" | base64 --decode > apps/mobile-rn/android/app/google-services.json test -s apps/mobile-rn/android/app/release.keystore test -s apps/mobile-rn/android/app/google-services.json - name: Prepare Verified Whisper Model run: node scripts/ci/prepare-whisper-model.mjs - name: Verify Production Firebase and AdMob Configuration env: D3RO_FIREBASE_EXPECTED_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }} D3RO_FIREBASE_EXPECTED_PROJECT_NUMBER: ${{ secrets.FIREBASE_PROJECT_NUMBER }} D3RO_FIREBASE_EXPECTED_MOBILESDK_APP_ID: ${{ secrets.FIREBASE_MOBILESDK_APP_ID }} run: | export D3RO_ADMOB_APP_ID="$(node -p "require('./release/android-release-identity.json').adMobAppId")" export D3RO_ADMOB_BANNER_UNIT_ID="$(node -p "require('./release/android-release-identity.json').adMobBannerUnitId")" export D3RO_ADMOB_REWARDED_UNIT_ID="$(node -p "require('./release/android-release-identity.json').adMobRewardedUnitId")" npm run release:mobile:config - name: Test Mobile TypeScript and Jest run: | npm --prefix apps/mobile-rn run lint npm --prefix apps/mobile-rn run typecheck npm --prefix apps/mobile-rn run test:ci - name: Build Signed arm64 APK and AAB working-directory: apps/mobile-rn/android env: D3RO_RELEASE_STORE_FILE: ${{ github.workspace }}/apps/mobile-rn/android/app/release.keystore D3RO_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_STORE_PASSWORD }} D3RO_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }} D3RO_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }} D3RO_VERSION_NAME: ${{ steps.android-version.outputs.name }} D3RO_VERSION_CODE: ${{ steps.android-version.outputs.code }} run: | export D3RO_ADMOB_APP_ID="$(node -p "require('../../../release/android-release-identity.json').adMobAppId")" export D3RO_ADMOB_BANNER_UNIT_ID="$(node -p "require('../../../release/android-release-identity.json').adMobBannerUnitId")" export D3RO_ADMOB_REWARDED_UNIT_ID="$(node -p "require('../../../release/android-release-identity.json').adMobRewardedUnitId")" ./gradlew :app:assembleRelease :app:bundleRelease -PreactNativeArchitectures=arm64-v8a --no-daemon - name: Materialize Release Evidence Signing Key shell: bash env: ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_B64: ${{ secrets.ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_B64 }} run: | set -euo pipefail umask 077 test -n "$ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_B64" printf '%s' "$ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_B64" | base64 --decode > apps/mobile-rn/android/app/release-evidence-private.pem test -s apps/mobile-rn/android/app/release-evidence-private.pem - name: Verify Release BuildConfig, Signature, ABI, Bundle, Ads, and Offline Model shell: bash env: D3RO_VERSION_NAME: ${{ steps.android-version.outputs.name }} D3RO_VERSION_CODE: ${{ steps.android-version.outputs.code }} run: | set -euo pipefail D3RO_ADMOB_APP_ID="$(node -p "require('./release/android-release-identity.json').adMobAppId")" D3RO_ADMOB_BANNER_UNIT_ID="$(node -p "require('./release/android-release-identity.json').adMobBannerUnitId")" D3RO_ADMOB_REWARDED_UNIT_ID="$(node -p "require('./release/android-release-identity.json').adMobRewardedUnitId")" ANDROID_UPLOAD_CERT_SHA256="$(node -p "require('./release/android-release-identity.json').uploadCertificateSha256")" PLAY_APP_SIGNING_CERT_SHA256="$(node -p "require('./release/android-release-identity.json').playAppSigningCertificateSha256")" APK=apps/mobile-rn/android/app/build/outputs/apk/release/app-release.apk AAB=apps/mobile-rn/android/app/build/outputs/bundle/release/app-release.aab test -f "$APK" test -f "$AAB" node scripts/ci/verify-mobile-build-config.mjs release \ | tee apps/mobile-rn/android/app/build/outputs/release-build-config.json node scripts/ci/create-mobile-release-evidence.mjs \ --apk "$APK" \ --aab "$AAB" \ --bundletool "$RUNNER_TEMP/bundletool-all-1.18.3.jar" \ --repository "$GITHUB_REPOSITORY" \ --commit-sha "$GITHUB_SHA" \ --tree-sha "$(git rev-parse "${GITHUB_SHA}^{tree}")" \ --git-ref "$GITHUB_REF" \ --workflow-identity "$GITHUB_WORKFLOW_REF" \ --run-id "$GITHUB_RUN_ID" \ --run-attempt "$GITHUB_RUN_ATTEMPT" \ --runner-identity "$RUNNER_NAME:$RUNNER_OS:$RUNNER_ARCH" \ --expected-admob-app-id "$D3RO_ADMOB_APP_ID" \ --expected-upload-cert-sha256 "$ANDROID_UPLOAD_CERT_SHA256" \ --expected-version-name "$D3RO_VERSION_NAME" \ --expected-version-code "$D3RO_VERSION_CODE" \ --private-key apps/mobile-rn/android/app/release-evidence-private.pem \ --snapshot-dir apps/mobile-rn/android/app/build/outputs/release-snapshot VERIFIER_SHA256="$(sha256sum scripts/ci/verify-android-artifact.mjs | awk '{print $1}')" BUNDLETOOL_SHA256="a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29" node scripts/ci/prepare-mobile-release-publication.mjs \ --source-root apps/mobile-rn/android/app/build/outputs/release-snapshot \ --apk apps/mobile-rn/android/app/build/outputs/release-snapshot/app-release.apk \ --aab apps/mobile-rn/android/app/build/outputs/release-snapshot/app-release.aab \ --evidence apps/mobile-rn/android/app/build/outputs/release-snapshot/release-artifact-evidence.json \ --public-key release/mobile-release-evidence-public.pem \ --destination-dir apps/mobile-rn/android/app/build/outputs/release-publication \ --expected-admob-app-id "$D3RO_ADMOB_APP_ID" \ --expected-upload-cert-sha256 "$ANDROID_UPLOAD_CERT_SHA256" \ --expected-version-name "$D3RO_VERSION_NAME" \ --expected-version-code "$D3RO_VERSION_CODE" \ --expected-repository "$GITHUB_REPOSITORY" \ --expected-commit-sha "$GITHUB_SHA" \ --expected-tree-sha "$(git rev-parse "${GITHUB_SHA}^{tree}")" \ --expected-git-ref "$GITHUB_REF" \ --expected-workflow-identity "$GITHUB_WORKFLOW_REF" \ --expected-run-id "$GITHUB_RUN_ID" \ --expected-run-attempt "$GITHUB_RUN_ATTEMPT" \ --expected-runner-identity "$RUNNER_NAME:$RUNNER_OS:$RUNNER_ARCH" \ --expected-verifier-sha256 "$VERIFIER_SHA256" \ --expected-bundletool-sha256 "$BUNDLETOOL_SHA256" node scripts/ci/verify-android-app-links.mjs \ --expected-play-app-signing-cert-sha256 "$PLAY_APP_SIGNING_CERT_SHA256" \ --forbidden-upload-cert-sha256 "$ANDROID_UPLOAD_CERT_SHA256" \ | tee apps/mobile-rn/android/app/build/outputs/release-app-links-evidence.json sha256sum apps/mobile-rn/android/app/build/outputs/release-publication/app-release.apk \ apps/mobile-rn/android/app/build/outputs/release-publication/app-release.aab \ | tee apps/mobile-rn/android/app/build/outputs/release-publication/SHA256SUMS.txt - name: Remove Materialized Release Secrets if: always() shell: bash run: rm -f apps/mobile-rn/android/app/release.keystore apps/mobile-rn/android/app/google-services.json apps/mobile-rn/android/app/release-evidence-private.pem - name: Upload Restricted Play Console AAB Handoff uses: actions/upload-artifact@v4 with: name: android-play-console-handoff path: | apps/mobile-rn/android/app/build/outputs/release-publication/app-release.aab apps/mobile-rn/android/app/build/outputs/release-publication/android-release-evidence.json apps/mobile-rn/android/app/build/outputs/release-publication/android-publication-manifest.json apps/mobile-rn/android/app/build/outputs/release-publication/SHA256SUMS.txt apps/mobile-rn/android/app/build/outputs/release-snapshot/release-artifact-verification.json apps/mobile-rn/android/app/build/outputs/release-build-config.json apps/mobile-rn/android/app/build/outputs/release-app-links-evidence.json retention-days: 7 if-no-files-found: error # ────────────────────────────────────────────────────────────────── # 4. Build & Containerize Admin Dashboard # ────────────────────────────────────────────────────────────────── package-admin-docker: name: Build & Publish Admin Docker Image needs: release-preflight runs-on: ubuntu-latest steps: - name: Checkout Code uses: actions/checkout@v4 - name: Set up Docker Buildx uses: actions/setup-buildx-action@v3 - name: Log in to GitHub Container Registry (GHCR) uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository }}/admin-console - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: . file: ./apps/admin/Dockerfile push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} # ────────────────────────────────────────────────────────────────── # 5. Create GitHub Release & Upload Checksums # ────────────────────────────────────────────────────────────────── publish-release: name: Publish Official GitHub Release needs: [package-windows, package-macos, package-android, package-admin-docker] runs-on: ubuntu-latest steps: - name: Checkout Code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Re-establish Trusted Release Identity id: release-identity shell: bash env: DISPATCH_VERSION: ${{ inputs.version }} run: | set -euo pipefail git fetch --no-tags origin main if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then test "$GITHUB_REF" = "refs/heads/main" test "$GITHUB_SHA" = "$(git rev-parse origin/main)" VERSION_NAME="$DISPATCH_VERSION" RELEASE_TAG="v$VERSION_NAME" else [[ "$GITHUB_REF" == refs/tags/v* ]] VERSION_NAME="${GITHUB_REF_NAME#v}" RELEASE_TAG="$GITHUB_REF_NAME" git merge-base --is-ancestor "$GITHUB_SHA" origin/main fi SSOT_VERSION="$(node -p "require('./release/product-version.json').version")" [[ "$VERSION_NAME" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] test "$VERSION_NAME" = "$SSOT_VERSION" test "$RELEASE_TAG" = "v$VERSION_NAME" printf 'name=%s\n' "$VERSION_NAME" >> "$GITHUB_OUTPUT" printf 'tag=%s\n' "$RELEASE_TAG" >> "$GITHUB_OUTPUT" - name: Setup Node.js 24 uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' - name: Download Windows Artifacts uses: actions/download-artifact@v4 with: name: windows-release-assets path: release-dist/ - name: Download macOS Artifacts uses: actions/download-artifact@v4 with: name: macos-release-assets path: release-dist/ - name: Generate SHA-256 Checksums run: | set -euo pipefail find release-dist -type f ! -name SHA256SUMS.txt -print0 \ | sort -z \ | xargs -0 sha256sum > release-dist/SHA256SUMS.txt cat release-dist/SHA256SUMS.txt - name: Extract Canonical Release Notes run: >- node scripts/ci/extract-release-notes.mjs --version "${{ steps.release-identity.outputs.name }}" --output release-notes.md - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: files: | release-dist/* draft: false prerelease: false body_path: release-notes.md generate_release_notes: false tag_name: ${{ steps.release-identity.outputs.tag }} fail_on_unmatched_files: true overwrite_files: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Publish to Forgejo Release and Update Feed env: FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} FORGEJO_RELEASE_TAG: ${{ steps.release-identity.outputs.tag }} FORGEJO_RELEASE_DIR: release-dist run: node scripts/ci/publish-forgejo-release.mjs