Zum Inhalt

Deployment-Gesamtkonzept: Artifact Promotion mit Dev→Prod Stufenmodell

Status: Konzept — Stand 26.03.2026
Ziel: Jeder Core-Bugfix wird automatisch auf alle Clients (Dev) deployed.
Nach erfolgreichem Test: Promotion auf Prod mit einem Klick — ohne Rebuild.


Inhaltsverzeichnis

  1. Architektur-Überblick
  2. IST vs. SOLL
  3. Core-Repo Workflows
  4. Client-Repo Workflows (Templates)
  5. Reusable Workflows (Build)
  6. Reusable Workflows (Deploy)
  7. Environment-Handling bei Mobile
  8. Secrets pro Client-Repo
  9. Client-Registry Format
  10. Kompletter Flow: Schritt für Schritt
  11. Migration vom IST-Zustand
  12. Automatisches Deployment-Setup bei Client-Erstellung
  13. FAQ

1. Architektur-Überblick

Grundprinzipien

  1. Eine Pipeline für alle — Core hat KEINE eigenen Deployment-Workflows. Tech-Schuppen wird als regulärer Client behandelt (Canary).
  2. Artifact Promotion — Build einmal, deploye überall. Kein Rebuild bei Prod-Promotion (Web).
  3. Tag-basiertes Pinning — Clients referenzieren Core per Git-Tag (ref: v1.0.7), nicht ref: main.
  4. Dev→Prod Stufenmodell — Jeder Build landet zuerst auf Dev. Prod erst nach manuellem OK.

Architektur-Diagramm

Core-Repo (easySale)
├── .github/workflows/
│   ├── tag-release.yml                    ← Erstellt Git-Tag bei Version-Bump
│   ├── notify-clients.yml                 ← Benachrichtigt alle Clients
│   ├── client-build-web-reusable.yml      ← Reusable: Web Build → Artifact
│   ├── client-build-android-reusable.yml  ← Reusable: Android Build → Artifact
│   ├── client-build-ios-reusable.yml      ← Reusable: iOS Build → Artifact
│   ├── client-deploy-web-reusable.yml     ← Reusable: Artifact → Firebase
│   ├── client-deploy-android-reusable.yml ← Reusable: AAB → Google Play
│   ├── client-deploy-ios-reusable.yml     ← Reusable: IPA → TestFlight
│   └── promote-all-clients.yml            ← Bulk-Promotion aller Clients
├── .github/client-registry.json           ← Welche Clients werden notifiziert
└── core/, onboarding/, scripts/ ...       ← App-Code + Tooling

Client-Repos (easysale-client-{name})
├── .github/workflows/
│   ├── auto-deploy.yml                    ← Build + Deploy auf Dev (automatisch)
│   ├── promote-to-prod.yml                ← Artifact → Prod (manuell)
│   ├── env-health-check.yml               ← Infra-Vergleich Dev vs Prod
│   └── sync-prod-to-dev.yml               ← Daten Prod→Dev
├── erp/                                   ← Flutter ERP (Web only)
├── shop/                                  ← Flutter Shop (Web + Android + iOS)
└── firebase/                              ← .firebaserc, Rules, Functions

2. IST vs. SOLL

Aspekt IST (aktuell) SOLL (neu)
Core-Deployment 3 eigene Workflows (web/android/ios) Keine — Core = Tech-Schuppen Client
Client-Deployment Direkt auf Production Erst Dev, dann Prod
Core-Referenz ref: main (floating) ref: v1.0.7 (getaggter Commit)
Build + Deploy Gekoppelt in einem Workflow Getrennt: Build → Artifact → Deploy
Prod-Promotion Neubau erforderlich Kein Rebuild (Web: Artifact Promotion)
Mobile Dev-Test Kein separater Dev-Kanal Internal Testing / TestFlight mit Dev-Backend
Pipeline-Konsistenz Core-Pipeline ≠ Client-Pipeline Eine einzige Pipeline für alle
Determinismus Jeder Build kann anders ausfallen Identisch durch getaggten Core-Ref

Workflows die ENTFALLEN

Workflow Grund
web-core-deployment.yml Ersetzt durch Tech-Schuppen Client-Deployment
android-core-deployment.yml Ersetzt durch Tech-Schuppen Client-Deployment
ios-core-deployment.yml Ersetzt durch Tech-Schuppen Client-Deployment
client-deploy-reusable.yml (alt) Ersetzt durch getrennte Build + Deploy Workflows
web-clients-deployment.yml Bereits deprecated

3. Core-Repo Workflows

3.1 tag-release.yml — Automatisches Tagging

Trigger: Push auf main mit Änderung in core/shared/pubspec.yaml
Funktion: Liest Version aus pubspec.yaml, erstellt Git-Tag v{version}

name: Tag Release

on:
  push:
    branches: [main]
    paths: ['core/shared/pubspec.yaml']

jobs:
  tag:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.version.outputs.version }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2

      # Nur taggen wenn Version tatsächlich geändert wurde
      - name: Check Version Bump
        id: check
        run: |
          PREV=$(git show HEAD~1:core/shared/pubspec.yaml 2>/dev/null \
            | grep '^version:' | awk '{print $2}' || echo "0.0.0")
          CURR=$(grep '^version:' core/shared/pubspec.yaml | awk '{print $2}')
          if [ "$PREV" != "$CURR" ]; then
            echo "bumped=true" >> "$GITHUB_OUTPUT"
            echo "Version bumped: $PREV → $CURR"
          else
            echo "bumped=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Extract Version
        if: steps.check.outputs.bumped == 'true'
        id: version
        run: |
          VERSION=$(grep '^version:' core/shared/pubspec.yaml | awk '{print $2}')
          echo "version=v$VERSION" >> "$GITHUB_OUTPUT"

      - name: Create Git Tag
        if: steps.check.outputs.bumped == 'true'
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          TAG="${{ steps.version.outputs.version }}"
          if git rev-parse "$TAG" >/dev/null 2>&1; then
            echo "::warning::Tag $TAG existiert bereits — übersprungen."
          else
            git tag "$TAG"
            git push origin "$TAG"
            echo "✅ Tag $TAG erstellt"
          fi

3.2 notify-clients.yml — Client-Benachrichtigung

Trigger: Push auf main oder Version-Tags
Funktion: Liest Client-Registry, sendet repository_dispatch mit Core-Version

name: Notify Clients on Core Update

on:
  push:
    branches: [main]
    paths: ['core/shared/pubspec.yaml']
    tags: ['v*']

jobs:
  load-clients:
    name: Load Client Registry
    runs-on: ubuntu-latest
    outputs:
      clients: ${{ steps.read.outputs.clients }}
      has-clients: ${{ steps.read.outputs.has-clients }}
      core-version: ${{ steps.version.outputs.version }}
    steps:
      - uses: actions/checkout@v4

      - name: Extract Core Version
        id: version
        run: |
          VERSION=$(grep '^version:' core/shared/pubspec.yaml | awk '{print $2}')
          echo "version=v$VERSION" >> "$GITHUB_OUTPUT"

      - name: Read Client Registry
        id: read
        run: |
          BRANCH="${GITHUB_REF_NAME}"
          if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then
            BRANCH=$(echo "$BRANCH" | sed 's/\..*//')
          fi
          CLIENTS=$(jq -r --arg b "$BRANCH" \
            'if .[$b] | type == "object" then
               [.[$b].canary[]?, .[$b].clients[]?]
             else
               .[$b] // []
             end | @json' .github/client-registry.json)
          echo "clients=$CLIENTS" >> "$GITHUB_OUTPUT"
          if [ "$CLIENTS" = "[]" ] || [ -z "$CLIENTS" ]; then
            echo "has-clients=false" >> "$GITHUB_OUTPUT"
          else
            echo "has-clients=true" >> "$GITHUB_OUTPUT"
          fi

  notify:
    name: Notify – ${{ matrix.client }}
    needs: load-clients
    if: needs.load-clients.outputs.has-clients == 'true'
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        client: ${{ fromJson(needs.load-clients.outputs.clients) }}
    steps:
      - name: Send Repository Dispatch
        uses: peter-evans/repository-dispatch@v3
        with:
          token: ${{ secrets.CLIENT_REPOS_PAT }}
          repository: Tech-Schuppen/${{ matrix.client }}
          event-type: core-updated
          client-payload: >-
            {
              "branch": "${{ github.ref_name }}",
              "sha": "${{ github.sha }}",
              "ref_type": "${{ github.ref_type }}",
              "core_version": "${{ needs.load-clients.outputs.core-version }}"
            }

3.3 promote-all-clients.yml — Bulk-Promotion

Trigger: Manuell (workflow_dispatch)
Funktion: Sendet promote-to-prod Event an alle Clients

name: Promote All Clients to Production

on:
  workflow_dispatch:

jobs:
  load-clients:
    runs-on: ubuntu-latest
    outputs:
      clients: ${{ steps.read.outputs.clients }}
    steps:
      - uses: actions/checkout@v4
      - name: Read Client Registry
        id: read
        run: |
          CLIENTS=$(jq -r '
            [.[] | if type == "object" then
              (.canary[]?, .clients[]?)
            else
              .[]
            end] | unique | @json' .github/client-registry.json)
          echo "clients=$CLIENTS" >> "$GITHUB_OUTPUT"

  promote:
    needs: load-clients
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        client: ${{ fromJson(needs.load-clients.outputs.clients) }}
    steps:
      - name: Trigger Production Promotion
        uses: peter-evans/repository-dispatch@v3
        with:
          token: ${{ secrets.CLIENT_REPOS_PAT }}
          repository: Tech-Schuppen/${{ matrix.client }}
          event-type: promote-to-prod

4. Client-Repo Workflows (Templates)

4.1 auto-deploy.yml — Build + Deploy auf Dev

Dieses Template wird bei Client-Erstellung in .github/workflows/ kopiert.

# =============================================================================
# auto-deploy.yml — Baut alle Plattformen + deployed auf Dev
#
# Trigger:
#   1. repository_dispatch von Core (core-updated) → automatisch
#   2. Push auf main Branch → automatisch
#   3. Manuell → Plattform und Core-Version wählbar
# =============================================================================

name: Build & Deploy to Dev

on:
  repository_dispatch:
    types: [core-updated]
  push:
    branches: [main]
  workflow_dispatch:
    inputs:
      core_version:
        description: 'Core Version Tag (leer = aus Event oder latest)'
        required: false
      platforms:
        description: 'Plattformen'
        type: choice
        options: [all, web-only, android-only, ios-only]
        default: all

jobs:
  # ═══════════════════════════════════════════════════════════════════════════
  # RESOLVE: Version + Plattformen bestimmen
  # ═══════════════════════════════════════════════════════════════════════════
  resolve:
    runs-on: ubuntu-latest
    outputs:
      core-version: ${{ steps.version.outputs.result }}
      build-web: ${{ steps.platforms.outputs.web }}
      build-android: ${{ steps.platforms.outputs.android }}
      build-ios: ${{ steps.platforms.outputs.ios }}
    steps:
      - name: Resolve Core Version
        id: version
        run: |
          # Priorität: 1. Manuell, 2. Dispatch-Payload, 3. Latest Tag
          if [ -n "${{ inputs.core_version }}" ]; then
            echo "result=${{ inputs.core_version }}" >> "$GITHUB_OUTPUT"
          elif [ -n "${{ github.event.client_payload.core_version }}" ]; then
            echo "result=${{ github.event.client_payload.core_version }}" >> "$GITHUB_OUTPUT"
          else
            LATEST=$(curl -sf https://api.github.com/repos/Tech-Schuppen/easySale/tags \
              -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
              | python3 -c "import json,sys; tags=json.load(sys.stdin); print(tags[0]['name'] if tags else 'main')")
            echo "result=$LATEST" >> "$GITHUB_OUTPUT"
          fi

      - name: Determine Platforms
        id: platforms
        run: |
          P="${{ inputs.platforms || 'all' }}"
          [[ "$P" == "all" || "$P" == "web-only" ]]     && echo "web=true"     >> "$GITHUB_OUTPUT" || echo "web=false"     >> "$GITHUB_OUTPUT"
          [[ "$P" == "all" || "$P" == "android-only" ]]  && echo "android=true" >> "$GITHUB_OUTPUT" || echo "android=false" >> "$GITHUB_OUTPUT"
          [[ "$P" == "all" || "$P" == "ios-only" ]]      && echo "ios=true"     >> "$GITHUB_OUTPUT" || echo "ios=false"     >> "$GITHUB_OUTPUT"

  # ═══════════════════════════════════════════════════════════════════════════
  # BUILD — Alle Plattformen parallel
  # ═══════════════════════════════════════════════════════════════════════════

  # ── Web Build (1x — environment-agnostisch, Config ist im Asset-Bundle) ──
  build-web:
    needs: resolve
    if: needs.resolve.outputs.build-web == 'true'
    uses: Tech-Schuppen/easySale/.github/workflows/client-build-web-reusable.yml@main
    with:
      core_version: ${{ needs.resolve.outputs.core-version }}
    secrets:
      SENTRY_DSN: ${{ secrets.SENTRY_DSN }}

  # ── Android Build (2x — Dev + Prod, wegen google-services.json) ─────────
  build-android-dev:
    needs: resolve
    if: needs.resolve.outputs.build-android == 'true'
    uses: Tech-Schuppen/easySale/.github/workflows/client-build-android-reusable.yml@main
    with:
      core_version: ${{ needs.resolve.outputs.core-version }}
      environment: development
    secrets:
      SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
      ANDROID_KEYSTORE_SHOP_BASE64: ${{ secrets.ANDROID_KEYSTORE_SHOP_BASE64 }}
      ANDROID_KEYSTORE_SHOP_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_SHOP_PASSWORD }}
      ANDROID_KEY_SHOP_ALIAS: ${{ secrets.ANDROID_KEY_SHOP_ALIAS }}
      ANDROID_KEY_SHOP_PASSWORD: ${{ secrets.ANDROID_KEY_SHOP_PASSWORD }}
      GOOGLE_SERVICES_JSON_SHOP_DEV: ${{ secrets.GOOGLE_SERVICES_JSON_SHOP_DEV }}
      SHOP_APP_ICON_BASE64: ${{ secrets.SHOP_APP_ICON_BASE64 }}

  build-android-prod:
    needs: resolve
    if: needs.resolve.outputs.build-android == 'true'
    uses: Tech-Schuppen/easySale/.github/workflows/client-build-android-reusable.yml@main
    with:
      core_version: ${{ needs.resolve.outputs.core-version }}
      environment: production
    secrets:
      SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
      ANDROID_KEYSTORE_SHOP_BASE64: ${{ secrets.ANDROID_KEYSTORE_SHOP_BASE64 }}
      ANDROID_KEYSTORE_SHOP_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_SHOP_PASSWORD }}
      ANDROID_KEY_SHOP_ALIAS: ${{ secrets.ANDROID_KEY_SHOP_ALIAS }}
      ANDROID_KEY_SHOP_PASSWORD: ${{ secrets.ANDROID_KEY_SHOP_PASSWORD }}
      GOOGLE_SERVICES_JSON_SHOP_PROD: ${{ secrets.GOOGLE_SERVICES_JSON_SHOP_PROD }}
      SHOP_APP_ICON_BASE64: ${{ secrets.SHOP_APP_ICON_BASE64 }}

  # ── iOS Build (2x — Dev + Prod, wegen GoogleService-Info.plist) ─────────
  build-ios-dev:
    needs: resolve
    if: needs.resolve.outputs.build-ios == 'true'
    uses: Tech-Schuppen/easySale/.github/workflows/client-build-ios-reusable.yml@main
    with:
      core_version: ${{ needs.resolve.outputs.core-version }}
      environment: development
    secrets:
      SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
      IOS_DIST_CERTIFICATE_BASE64: ${{ secrets.IOS_DIST_CERTIFICATE_BASE64 }}
      IOS_DIST_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DIST_CERTIFICATE_PASSWORD }}
      IOS_PROVISION_PROFILE_SHOP_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_SHOP_BASE64 }}
      SHOP_APP_ICON_BASE64: ${{ secrets.SHOP_APP_ICON_BASE64 }}

  build-ios-prod:
    needs: resolve
    if: needs.resolve.outputs.build-ios == 'true'
    uses: Tech-Schuppen/easySale/.github/workflows/client-build-ios-reusable.yml@main
    with:
      core_version: ${{ needs.resolve.outputs.core-version }}
      environment: production
    secrets:
      SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
      IOS_DIST_CERTIFICATE_BASE64: ${{ secrets.IOS_DIST_CERTIFICATE_BASE64 }}
      IOS_DIST_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DIST_CERTIFICATE_PASSWORD }}
      IOS_PROVISION_PROFILE_SHOP_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_SHOP_BASE64 }}
      SHOP_APP_ICON_BASE64: ${{ secrets.SHOP_APP_ICON_BASE64 }}

  # ═══════════════════════════════════════════════════════════════════════════
  # DEPLOY DEV — Automatisch nach Build
  # ═══════════════════════════════════════════════════════════════════════════

  deploy-web-dev:
    needs: build-web
    if: always() && needs.build-web.result == 'success'
    uses: Tech-Schuppen/easySale/.github/workflows/client-deploy-web-reusable.yml@main
    with:
      environment: dev
      artifact-name: ${{ needs.build-web.outputs.artifact-name }}
    secrets:
      FIREBASE_SA: ${{ secrets.FIREBASE_SA_DEV }}

  deploy-android-dev:
    needs: build-android-dev
    if: always() && needs.build-android-dev.result == 'success'
    uses: Tech-Schuppen/easySale/.github/workflows/client-deploy-android-reusable.yml@main
    with:
      play-track: internal
      artifact-name: ${{ needs.build-android-dev.outputs.artifact-name }}
    secrets:
      GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}

  deploy-ios-dev:
    needs: build-ios-dev
    if: always() && needs.build-ios-dev.result == 'success'
    uses: Tech-Schuppen/easySale/.github/workflows/client-deploy-ios-reusable.yml@main
    with:
      artifact-name: ${{ needs.build-ios-dev.outputs.artifact-name }}
    secrets:
      APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
      APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
      APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}

  # ═══════════════════════════════════════════════════════════════════════════
  # PIN VERSION — Core-Ref im Client-Repo aktualisieren
  # ═══════════════════════════════════════════════════════════════════════════

  pin-version:
    needs: [resolve, deploy-web-dev]
    if: always() && needs.resolve.result == 'success'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: Pin Core Version in pubspec.yaml
        run: |
          VERSION="${{ needs.resolve.outputs.core-version }}"
          for f in erp/pubspec.yaml shop/pubspec.yaml; do
            [ -f "$f" ] && sed -i "s/ref: .*/ref: $VERSION/" "$f"
          done
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add erp/pubspec.yaml shop/pubspec.yaml 2>/dev/null || true
          git diff --cached --quiet || git commit -m "chore: pin core to $VERSION"
          git push

4.2 promote-to-prod.yml — Promotion auf Production

# =============================================================================
# promote-to-prod.yml — Promoted vorhandene Artifacts auf Production
#
# Web:     Dasselbe Artifact → Firebase Prod (kein Rebuild)
# Android: Prod-AAB (bereits gebaut) → Play Store Production Track
# iOS:     Prod-IPA (bereits gebaut) → TestFlight (manuell in ASC freigeben)
#
# Trigger:
#   1. repository_dispatch (promote-to-prod) vom Core-Repo
#   2. Manuell mit optionaler Run-ID
# =============================================================================

name: Promote to Production

on:
  repository_dispatch:
    types: [promote-to-prod]
  workflow_dispatch:
    inputs:
      run_id:
        description: 'Build Run-ID (leer = letzter erfolgreicher Build)'
        required: false
      platforms:
        description: 'Plattformen'
        type: choice
        options: [all, web-only, android-only, ios-only]
        default: all

jobs:
  # ── Letzte Build-Artifacts finden ───────────────────────────────────────
  find-artifacts:
    runs-on: ubuntu-latest
    outputs:
      run-id: ${{ steps.find.outputs.run-id }}
      web-artifact: ${{ steps.find.outputs.web-artifact }}
      android-prod-artifact: ${{ steps.find.outputs.android-prod-artifact }}
      ios-prod-artifact: ${{ steps.find.outputs.ios-prod-artifact }}
    steps:
      - name: Find Latest Build Artifacts
        id: find
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          RUN_ID="${{ inputs.run_id || github.event.client_payload.run_id || '' }}"
          if [ -z "$RUN_ID" ]; then
            RUN_ID=$(gh api "repos/${{ github.repository }}/actions/workflows/auto-deploy.yml/runs?status=success&per_page=1" \
              --jq '.workflow_runs[0].id')
          fi
          echo "run-id=$RUN_ID" >> "$GITHUB_OUTPUT"

          ARTIFACTS=$(gh api "repos/${{ github.repository }}/actions/runs/$RUN_ID/artifacts" --paginate)
          echo "web-artifact=$(echo "$ARTIFACTS" | jq -r '[.artifacts[] | select(.name | startswith("web-"))][0].name // ""')" >> "$GITHUB_OUTPUT"
          echo "android-prod-artifact=$(echo "$ARTIFACTS" | jq -r '[.artifacts[] | select(.name | startswith("android-prod-"))][0].name // ""')" >> "$GITHUB_OUTPUT"
          echo "ios-prod-artifact=$(echo "$ARTIFACTS" | jq -r '[.artifacts[] | select(.name | startswith("ios-prod-"))][0].name // ""')" >> "$GITHUB_OUTPUT"

  # ── Web → Firebase Production ───────────────────────────────────────────
  deploy-web-prod:
    needs: find-artifacts
    if: >-
      (inputs.platforms || 'all') != 'android-only' &&
      (inputs.platforms || 'all') != 'ios-only' &&
      needs.find-artifacts.outputs.web-artifact != ''
    uses: Tech-Schuppen/easySale/.github/workflows/client-deploy-web-reusable.yml@main
    with:
      environment: production
      artifact-name: ${{ needs.find-artifacts.outputs.web-artifact }}
      artifact-run-id: ${{ needs.find-artifacts.outputs.run-id }}
    secrets:
      FIREBASE_SA: ${{ secrets.FIREBASE_SA_PROD }}

  # ── Android → Play Store Production ─────────────────────────────────────
  deploy-android-prod:
    needs: find-artifacts
    if: >-
      (inputs.platforms || 'all') != 'web-only' &&
      (inputs.platforms || 'all') != 'ios-only' &&
      needs.find-artifacts.outputs.android-prod-artifact != ''
    uses: Tech-Schuppen/easySale/.github/workflows/client-deploy-android-reusable.yml@main
    with:
      play-track: production
      artifact-name: ${{ needs.find-artifacts.outputs.android-prod-artifact }}
      artifact-run-id: ${{ needs.find-artifacts.outputs.run-id }}
    secrets:
      GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}

  # ── iOS → TestFlight (für App Store Review) ─────────────────────────────
  deploy-ios-prod:
    needs: find-artifacts
    if: >-
      (inputs.platforms || 'all') != 'web-only' &&
      (inputs.platforms || 'all') != 'android-only' &&
      needs.find-artifacts.outputs.ios-prod-artifact != ''
    uses: Tech-Schuppen/easySale/.github/workflows/client-deploy-ios-reusable.yml@main
    with:
      artifact-name: ${{ needs.find-artifacts.outputs.ios-prod-artifact }}
      artifact-run-id: ${{ needs.find-artifacts.outputs.run-id }}
    secrets:
      APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
      APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
      APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}

5. Reusable Workflows (Build)

5.1 client-build-web-reusable.yml

Web-Build ist environment-agnostisch — die Firebase-Config wird per --dart-define=ENVIRONMENT=... gesteuert, aber die JSON-Config-Dateien für ALLE Environments liegen im Asset-Bundle. Beim Web-Deployment bestimmt die Deploy-Umgebung welches Firebase-Projekt angesprochen wird, nicht der Build. Daher: 1 Build für Dev + Prod.

Wichtig: Das funktioniert weil die App ENVIRONMENT per --dart-define beim Build einbrennt. Für Web-Deployments müssen wir den Build mit dem korrekten ENVIRONMENT-Flag erstellen. Da Web auf Firebase Hosting läuft und jedes Hosting-Projekt nur einen Environment bedient, können wir den Build environment-spezifisch machen und trotzdem Artifact Promotion nutzen — solange Dev und Prod dasselbe ENVIRONMENT-Flag brauchen.

Korrektur: Da --dart-define=ENVIRONMENT=development vs production den Build beeinflusst, brauchen wir auch bei Web 2 Builds. Allerdings ändert sich NUR das dart-define — der gesamte Dart-Code ist identisch. Beide Builds entstehen aus demselben Code-Stand und sind deterministisch.

name: Client Build – Web (Reusable)

on:
  workflow_call:
    inputs:
      core_version:
        type: string
        required: true
      environment:
        description: 'development oder production'
        type: string
        default: 'development'
      deploy_shop:
        type: boolean
        default: false
      flutter_build_args:
        type: string
        default: ''
      flutter_channel:
        type: string
        default: 'stable'
    secrets:
      SENTRY_DSN:
        required: false
    outputs:
      artifact-name:
        description: 'Name des Build-Artifacts'
        value: ${{ jobs.build.outputs.artifact-name }}

jobs:
  build:
    name: Build Web (${{ inputs.environment }})
    runs-on: ubuntu-latest
    timeout-minutes: 20
    outputs:
      artifact-name: ${{ steps.meta.outputs.artifact-name }}
    steps:
      - uses: actions/checkout@v4

      # ── Core-Version pinnen ─────────────────────────────────────────────
      - name: Pin Core Version
        run: |
          VERSION="${{ inputs.core_version }}"
          sed -i "s/ref: .*/ref: $VERSION/" erp/pubspec.yaml
          [ -f shop/pubspec.yaml ] && sed -i "s/ref: .*/ref: $VERSION/" shop/pubspec.yaml

      # ── Flutter ─────────────────────────────────────────────────────────
      - uses: subosito/flutter-action@v2
        with:
          channel: ${{ inputs.flutter_channel }}
          cache: true

      # ── ERP Build ───────────────────────────────────────────────────────
      - name: Build ERP Web
        working-directory: erp
        run: |
          flutter pub get
          flutter build web --release \
            --dart-define=ENVIRONMENT=${{ inputs.environment }} \
            --dart-define=SENTRY_DSN=${{ secrets.SENTRY_DSN }} \
            ${{ inputs.flutter_build_args }}

      # ── Shop Build (optional) ──────────────────────────────────────────
      - name: Build Shop Web
        if: inputs.deploy_shop
        working-directory: shop
        run: |
          flutter pub get
          flutter build web --release \
            --dart-define=ENVIRONMENT=${{ inputs.environment }} \
            --dart-define=SENTRY_DSN=${{ secrets.SENTRY_DSN }} \
            ${{ inputs.flutter_build_args }}

      # ── Rules & Functions vorbereiten ───────────────────────────────────
      - name: Prepare Deployment Payload
        run: |
          mkdir -p _artifact/hosting/erp
          cp -r erp/build/web/* _artifact/hosting/erp/
          if [ "${{ inputs.deploy_shop }}" = "true" ] && [ -d shop/build/web ]; then
            mkdir -p _artifact/hosting/shop
            cp -r shop/build/web/* _artifact/hosting/shop/
          fi
          # Rules + Functions Dateien kopieren
          mkdir -p _artifact/firebase
          [ -d firebase ] && cp -r firebase/* _artifact/firebase/ 2>/dev/null || true
          # Hosting Headers kopieren
          [ -f hosting_headers_core.json ] && cp hosting_headers_core.json _artifact/
          [ -f hosting_headers_extra.json ] && cp hosting_headers_extra.json _artifact/

      # ── Manifest ────────────────────────────────────────────────────────
      - name: Create Manifest
        id: meta
        run: |
          ENV="${{ inputs.environment }}"
          ARTIFACT_NAME="web-${ENV}-$(date +%Y%m%d-%H%M%S)-${{ inputs.core_version }}"
          echo "artifact-name=$ARTIFACT_NAME" >> "$GITHUB_OUTPUT"
          cat > _artifact/manifest.json << EOF
          {
            "platform": "web",
            "environment": "$ENV",
            "core_version": "${{ inputs.core_version }}",
            "client_sha": "${{ github.sha }}",
            "built_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
            "artifact_name": "$ARTIFACT_NAME"
          }
          EOF

      # ── Upload ──────────────────────────────────────────────────────────
      - uses: actions/upload-artifact@v4
        with:
          name: ${{ steps.meta.outputs.artifact-name }}
          path: _artifact/
          retention-days: 30

5.2 client-build-android-reusable.yml

name: Client Build – Android (Reusable)

on:
  workflow_call:
    inputs:
      core_version:
        type: string
        required: true
      environment:
        description: 'development oder production'
        type: string
        required: true
      flutter_channel:
        type: string
        default: 'stable'
    secrets:
      SENTRY_DSN:
        required: false
      ANDROID_KEYSTORE_SHOP_BASE64:
        required: true
      ANDROID_KEYSTORE_SHOP_PASSWORD:
        required: true
      ANDROID_KEY_SHOP_ALIAS:
        required: true
      ANDROID_KEY_SHOP_PASSWORD:
        required: true
      GOOGLE_SERVICES_JSON_SHOP:
        description: 'google-services.json (Base64) für das gewählte Environment'
        required: true
      SHOP_APP_ICON_BASE64:
        required: false
    outputs:
      artifact-name:
        value: ${{ jobs.build.outputs.artifact-name }}

jobs:
  build:
    name: Build Android (${{ inputs.environment }})
    runs-on: ubuntu-latest
    timeout-minutes: 30
    outputs:
      artifact-name: ${{ steps.meta.outputs.artifact-name }}
    steps:
      - uses: actions/checkout@v4

      - name: Pin Core Version
        run: |
          VERSION="${{ inputs.core_version }}"
          [ -f shop/pubspec.yaml ] && sed -i "s/ref: .*/ref: $VERSION/" shop/pubspec.yaml

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '17'

      - uses: subosito/flutter-action@v2
        with:
          channel: ${{ inputs.flutter_channel }}
          cache: true

      # ── App Icon (optional) ────────────────────────────────────────────
      - name: Prepare App Icon
        if: secrets.SHOP_APP_ICON_BASE64 != ''
        env:
          ICON_BASE64: ${{ secrets.SHOP_APP_ICON_BASE64 }}
        run: |
          if [ -n "$ICON_BASE64" ]; then
            pip3 install Pillow --quiet
            echo -n "$ICON_BASE64" | base64 --decode > /tmp/app_icon_source.png
            APP_ICON_PATH=/tmp/app_icon_source.png SHOP_APP_DIR=shop bash scripts/prepare_app_icons.sh 2>/dev/null || true
          fi

      # ── Code Signing ───────────────────────────────────────────────────
      - name: Decode Keystore
        run: echo -n "${{ secrets.ANDROID_KEYSTORE_SHOP_BASE64 }}" | base64 --decode > shop/android/app/shop-release.jks

      - name: Create key.properties
        run: |
          cat > shop/android/key.properties << EOF
          storePassword=${{ secrets.ANDROID_KEYSTORE_SHOP_PASSWORD }}
          keyPassword=${{ secrets.ANDROID_KEY_SHOP_PASSWORD }}
          keyAlias=${{ secrets.ANDROID_KEY_SHOP_ALIAS }}
          storeFile=shop-release.jks
          EOF

      - name: Decode google-services.json
        run: |
          echo -n "${{ secrets.GOOGLE_SERVICES_JSON_SHOP }}" | base64 --decode \
            > shop/android/app/google-services.json

      # ── Build ──────────────────────────────────────────────────────────
      - name: Build Shop AAB
        working-directory: shop
        run: |
          flutter pub get
          flutter build appbundle --release \
            --dart-define=ENVIRONMENT=${{ inputs.environment }} \
            --dart-define=SENTRY_DSN=${{ secrets.SENTRY_DSN }}

      # ── Artifact ───────────────────────────────────────────────────────
      - name: Create Manifest & Upload
        id: meta
        run: |
          ENV="${{ inputs.environment }}"
          ARTIFACT_NAME="android-${ENV}-$(date +%Y%m%d-%H%M%S)-${{ inputs.core_version }}"
          echo "artifact-name=$ARTIFACT_NAME" >> "$GITHUB_OUTPUT"
          mkdir -p _artifact
          cp shop/build/app/outputs/bundle/release/*.aab _artifact/
          cat > _artifact/manifest.json << EOF
          {
            "platform": "android",
            "environment": "$ENV",
            "core_version": "${{ inputs.core_version }}",
            "client_sha": "${{ github.sha }}",
            "built_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
          }
          EOF

      - uses: actions/upload-artifact@v4
        with:
          name: ${{ steps.meta.outputs.artifact-name }}
          path: _artifact/
          retention-days: 30

      - name: Cleanup Secrets
        if: always()
        run: |
          rm -f shop/android/app/shop-release.jks
          rm -f shop/android/key.properties
          rm -f shop/android/app/google-services.json

5.3 client-build-ios-reusable.yml

name: Client Build – iOS (Reusable)

on:
  workflow_call:
    inputs:
      core_version:
        type: string
        required: true
      environment:
        description: 'development oder production'
        type: string
        required: true
      flutter_channel:
        type: string
        default: 'stable'
    secrets:
      SENTRY_DSN:
        required: false
      IOS_DIST_CERTIFICATE_BASE64:
        required: true
      IOS_DIST_CERTIFICATE_PASSWORD:
        required: true
      IOS_PROVISION_PROFILE_SHOP_BASE64:
        required: true
      SHOP_APP_ICON_BASE64:
        required: false
    outputs:
      artifact-name:
        value: ${{ jobs.build.outputs.artifact-name }}

jobs:
  build:
    name: Build iOS (${{ inputs.environment }})
    runs-on: self-hosted       # macOS mit Xcode erforderlich
    timeout-minutes: 45
    outputs:
      artifact-name: ${{ steps.meta.outputs.artifact-name }}
    steps:
      - uses: actions/checkout@v4

      - name: Pin Core Version
        run: |
          VERSION="${{ inputs.core_version }}"
          [ -f shop/pubspec.yaml ] && sed -i '' "s/ref: .*/ref: $VERSION/" shop/pubspec.yaml

      - uses: subosito/flutter-action@v2
        with:
          channel: ${{ inputs.flutter_channel }}
          cache: true

      # ── App Icon (optional) ────────────────────────────────────────────
      - name: Prepare App Icon
        env:
          ICON_BASE64: ${{ secrets.SHOP_APP_ICON_BASE64 }}
        run: |
          if [ -n "$ICON_BASE64" ]; then
            pip3 install Pillow --break-system-packages --quiet
            echo -n "$ICON_BASE64" | base64 --decode > /tmp/app_icon_source.png
            APP_ICON_PATH=/tmp/app_icon_source.png SHOP_APP_DIR=shop bash scripts/prepare_app_icons.sh 2>/dev/null || true
          fi

      - name: Install Dependencies
        working-directory: shop
        run: flutter pub get

      - name: Install CocoaPods
        working-directory: shop/ios
        run: pod install --repo-update

      # ── Code Signing ───────────────────────────────────────────────────
      - name: Install Certificate
        env:
          CERT_BASE64: ${{ secrets.IOS_DIST_CERTIFICATE_BASE64 }}
          CERT_PASSWORD: ${{ secrets.IOS_DIST_CERTIFICATE_PASSWORD }}
          KEYCHAIN_PASSWORD: ${{ github.run_id }}
        run: |
          KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain
          security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
          security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
          CERT_PATH=$RUNNER_TEMP/certificate.p12
          echo -n "$CERT_BASE64" | base64 --decode -o "$CERT_PATH"
          security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security -A
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
          security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
          security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain
          security default-keychain -s "$KEYCHAIN_PATH"

      - name: Install Provisioning Profile
        env:
          PP_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_SHOP_BASE64 }}
        run: |
          PP_PATH=$RUNNER_TEMP/shop.mobileprovision
          echo -n "$PP_BASE64" | base64 --decode -o "$PP_PATH"
          security cms -D -i "$PP_PATH" > "$RUNNER_TEMP/profile.plist"
          PP_UUID=$(/usr/libexec/PlistBuddy -c "Print :UUID" "$RUNNER_TEMP/profile.plist")
          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          cp "$PP_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/${PP_UUID}.mobileprovision
          echo "PP_UUID=${PP_UUID}" >> $GITHUB_ENV

      - name: Inject Profile UUID
        working-directory: shop
        run: |
          sed -i '' \
            "s/PROVISIONING_PROFILE_SPECIFIER = \".*\";/PROVISIONING_PROFILE_SPECIFIER = \"$PP_UUID\";/g" \
            ios/Runner.xcodeproj/project.pbxproj

      # ── Build ──────────────────────────────────────────────────────────
      - name: Build Shop IPA
        working-directory: shop
        run: |
          /usr/libexec/PlistBuddy -c "Set :provisioningProfiles:de.easysale.app.demo $PP_UUID" \
            ios/ExportOptions.plist 2>/dev/null || \
          /usr/libexec/PlistBuddy -c "Add :provisioningProfiles:de.easysale.app.demo string $PP_UUID" \
            ios/ExportOptions.plist
          flutter build ipa --release \
            --dart-define=ENVIRONMENT=${{ inputs.environment }} \
            --dart-define=SENTRY_DSN=${{ secrets.SENTRY_DSN }} \
            --export-options-plist=ios/ExportOptions.plist

      # ── Artifact ───────────────────────────────────────────────────────
      - name: Create Manifest & Upload
        id: meta
        run: |
          ENV="${{ inputs.environment }}"
          ARTIFACT_NAME="ios-${ENV}-$(date +%Y%m%d-%H%M%S)-${{ inputs.core_version }}"
          echo "artifact-name=$ARTIFACT_NAME" >> "$GITHUB_OUTPUT"
          mkdir -p _artifact
          cp shop/build/ios/ipa/*.ipa _artifact/
          cat > _artifact/manifest.json << EOF
          {
            "platform": "ios",
            "environment": "$ENV",
            "core_version": "${{ inputs.core_version }}",
            "client_sha": "${{ github.sha }}",
            "built_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
          }
          EOF

      - uses: actions/upload-artifact@v4
        with:
          name: ${{ steps.meta.outputs.artifact-name }}
          path: _artifact/
          retention-days: 30

      - name: Cleanup
        if: always()
        run: |
          security delete-keychain $RUNNER_TEMP/build.keychain 2>/dev/null || true
          rm -rf ~/Library/MobileDevice/Provisioning\ Profiles/*.mobileprovision 2>/dev/null || true

6. Reusable Workflows (Deploy)

6.1 client-deploy-web-reusable.yml

name: Client Deploy – Web (Reusable)

on:
  workflow_call:
    inputs:
      environment:
        description: 'dev, staging, oder production'
        type: string
        required: true
      artifact-name:
        type: string
        required: true
      artifact-run-id:
        type: string
        default: ''
      deploy_functions:
        type: boolean
        default: true
      deploy_rules:
        type: boolean
        default: true
    secrets:
      FIREBASE_SA:
        required: true

jobs:
  deploy:
    name: Deploy Web → ${{ inputs.environment }}
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4    # Für deploy-Scripts + firebase configs

      - uses: actions/download-artifact@v4
        with:
          name: ${{ inputs.artifact-name }}
          path: _artifact/
          run-id: ${{ inputs.artifact-run-id || github.run_id }}
          github-token: ${{ github.token }}

      - uses: google-github-actions/auth@v2
        with:
          credentials_json: ${{ secrets.FIREBASE_SA }}

      - run: npm install -g firebase-tools

      - name: Resolve Firebase Project
        id: project
        run: |
          ENV="${{ inputs.environment }}"
          case "$ENV" in
            production|prod) ALIASES="production prod default" ;;
            staging)         ALIASES="staging default" ;;
            *)               ALIASES="dev development default" ;;
          esac
          PROJECT_ID=$(python3 -c "
          import json, sys
          d = json.load(open('firebase/.firebaserc'))
          p = d.get('projects', {})
          for alias in '$ALIASES'.split():
              if alias in p:
                  print(p[alias]); sys.exit(0)
          sys.exit(1)")
          echo "id=$PROJECT_ID" >> "$GITHUB_OUTPUT"

      # ── Rules & Functions (aus Checkout, nicht Artifact) ────────────────
      - name: Deploy Rules
        if: inputs.deploy_rules
        run: |
          [ -f deploy_rules.sh ] && bash deploy_rules.sh "${{ steps.project.outputs.id }}" \
            || firebase deploy --project "${{ steps.project.outputs.id }}" --only firestore:rules,firestore:indexes,storage --non-interactive

      - name: Deploy Functions
        if: inputs.deploy_functions
        run: |
          [ -f deploy_functions.sh ] && bash deploy_functions.sh "${{ steps.project.outputs.id }}" \
            || firebase deploy --project "${{ steps.project.outputs.id }}" --only functions --non-interactive

      # ── Hosting (aus Artifact) ─────────────────────────────────────────
      - name: Generate firebase.json
        run: |
          node -e "
            const fs = require('fs');
            let headers = [{ source: '**', headers: [
              {key: 'X-Content-Type-Options', value: 'nosniff'},
              {key: 'X-Frame-Options', value: 'DENY'},
              {key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin'}
            ]}];
            try {
              const core = JSON.parse(fs.readFileSync('_artifact/hosting_headers_core.json', 'utf8'));
              headers = core;
              try {
                const extra = JSON.parse(fs.readFileSync('_artifact/hosting_headers_extra.json', 'utf8'));
                if (extra.overrideHeaders) {
                  headers[0].headers = headers[0].headers.map(h =>
                    extra.overrideHeaders[h.key] !== undefined ? {key: h.key, value: extra.overrideHeaders[h.key]} : h);
                }
                if (extra.extraHeaders) {
                  const existing = new Set(headers[0].headers.map(h => h.key));
                  extra.extraHeaders.forEach(h => { if (!existing.has(h.key)) headers[0].headers.push(h); });
                }
              } catch(_) {}
            } catch(_) {}
            const hosting = [{
              target: 'erp', public: '_artifact/hosting/erp',
              ignore: ['firebase.json','**/.*','**/node_modules/**'],
              rewrites: [{source:'**',destination:'/index.html'}],
              headers
            }];
            if (fs.existsSync('_artifact/hosting/shop')) {
              hosting.push({
                target: 'shop', public: '_artifact/hosting/shop',
                ignore: ['firebase.json','**/.*','**/node_modules/**'],
                rewrites: [{source:'**',destination:'/index.html'}],
                headers
              });
            }
            fs.writeFileSync('_deploy_firebase.json', JSON.stringify({hosting}, null, 2));
          "

      - name: Deploy Hosting
        run: |
          firebase deploy \
            --project "${{ steps.project.outputs.id }}" \
            --only hosting \
            --config _deploy_firebase.json \
            --non-interactive

      - name: Summary
        if: always()
        run: |
          MANIFEST=$(cat _artifact/manifest.json 2>/dev/null || echo '{}')
          {
            echo "## 🚀 Web Deploy → ${{ inputs.environment }}"
            echo '```json'
            echo "$MANIFEST" | python3 -m json.tool 2>/dev/null || echo "$MANIFEST"
            echo '```'
          } >> "$GITHUB_STEP_SUMMARY"

6.2 client-deploy-android-reusable.yml

name: Client Deploy – Android (Reusable)

on:
  workflow_call:
    inputs:
      play-track:
        description: 'Google Play Track: internal, alpha, beta, production'
        type: string
        default: 'internal'
      artifact-name:
        type: string
        required: true
      artifact-run-id:
        type: string
        default: ''
    secrets:
      GOOGLE_PLAY_SERVICE_ACCOUNT_JSON:
        required: true

jobs:
  deploy:
    name: Deploy Android → ${{ inputs.play-track }}
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: ${{ inputs.artifact-name }}
          path: _artifact/
          run-id: ${{ inputs.artifact-run-id || github.run_id }}
          github-token: ${{ github.token }}

      - name: Upload to Google Play
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
          packageName: ${{ env.APP_ID_SHOP }}
          releaseFiles: _artifact/*.aab
          track: ${{ inputs.play-track }}
          status: completed

      - name: Summary
        if: always()
        run: |
          MANIFEST=$(cat _artifact/manifest.json 2>/dev/null || echo '{}')
          echo "## 📱 Android Deploy → ${{ inputs.play-track }}" >> "$GITHUB_STEP_SUMMARY"
          echo '```json' >> "$GITHUB_STEP_SUMMARY"
          echo "$MANIFEST" >> "$GITHUB_STEP_SUMMARY"
          echo '```' >> "$GITHUB_STEP_SUMMARY"

6.3 client-deploy-ios-reusable.yml

name: Client Deploy – iOS (Reusable)

on:
  workflow_call:
    inputs:
      artifact-name:
        type: string
        required: true
      artifact-run-id:
        type: string
        default: ''
    secrets:
      APP_STORE_CONNECT_API_KEY_ID:
        required: true
      APP_STORE_CONNECT_API_ISSUER_ID:
        required: true
      APP_STORE_CONNECT_API_KEY_BASE64:
        required: true

jobs:
  deploy:
    name: Deploy iOS → TestFlight
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: ${{ inputs.artifact-name }}
          path: _artifact/
          run-id: ${{ inputs.artifact-run-id || github.run_id }}
          github-token: ${{ github.token }}

      - name: Decode API Key
        run: |
          {
            echo 'APP_STORE_API_KEY<<ENDOFKEY'
            echo -n "${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}" | base64 --decode
            echo ENDOFKEY
          } >> $GITHUB_ENV

      - name: Upload to TestFlight
        uses: apple-actions/upload-testflight-build@v3
        with:
          app-path: _artifact/*.ipa
          issuer-id: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
          api-key-id: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          api-private-key: ${{ env.APP_STORE_API_KEY }}

      - name: Summary
        if: always()
        run: |
          MANIFEST=$(cat _artifact/manifest.json 2>/dev/null || echo '{}')
          echo "## 🍎 iOS Deploy → TestFlight" >> "$GITHUB_STEP_SUMMARY"
          echo '```json' >> "$GITHUB_STEP_SUMMARY"
          echo "$MANIFEST" >> "$GITHUB_STEP_SUMMARY"
          echo '```' >> "$GITHUB_STEP_SUMMARY"

7. Environment-Handling bei Mobile

Warum Mobile 2 Builds braucht (Web auch)

Die App verwendet --dart-define=ENVIRONMENT=development|production beim Build. Dieser Wert wird per String.fromEnvironment('ENVIRONMENT') gelesen und bestimmt welche firebase_config_{env}.json aus den Assets geladen wird.

Da dieses Flag zur Compile-Zeit eingebrannt wird, ist jeder Build environment-spezifisch. Daher: 2 Builds pro Plattform (Dev + Prod), beide parallel aus demselben Code-Stand.

auto-deploy.yml baut parallel:

  ┌─ build-web-dev        (ENVIRONMENT=development)   → Firebase Dev
  ├─ build-web-prod       (ENVIRONMENT=production)    → wartet als Artifact
  ├─ build-android-dev    (ENVIRONMENT=development)   → Internal Testing
  ├─ build-android-prod   (ENVIRONMENT=production)    → wartet als Artifact
  ├─ build-ios-dev        (ENVIRONMENT=development)   → TestFlight
  └─ build-ios-prod       (ENVIRONMENT=production)    → wartet als Artifact

promote-to-prod.yml:
  → Nimmt die prod-Artifacts (bereits gebaut!)
  → Deployed sie → kein Rebuild nötig

Dev/Prod Kanäle bei Mobile

Plattform Dev-Kanal Prod-Kanal Dev→Prod Promotion
Web Firebase Dev-Hosting Firebase Prod-Hosting Prod-Artifact deployen
Android Play Store Internal Testing Play Store Production Prod-AAB hochladen
iOS TestFlight (Dev-Build) TestFlight (Prod-Build) → App Store Review Prod-IPA hochladen + manuell freigeben

8. Secrets pro Client-Repo

Web (minimal)

Secret Beschreibung
FIREBASE_SA_DEV Firebase Service Account JSON für Dev-Projekt
FIREBASE_SA_PROD Firebase Service Account JSON für Prod-Projekt
SENTRY_DSN Sentry Error Tracking DSN

Android (zusätzlich)

Secret Beschreibung
ANDROID_KEYSTORE_SHOP_BASE64 Shop Keystore (.jks, Base64)
ANDROID_KEYSTORE_SHOP_PASSWORD Keystore Passwort
ANDROID_KEY_SHOP_ALIAS Key Alias
ANDROID_KEY_SHOP_PASSWORD Key Passwort
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON Play Console Service Account
GOOGLE_SERVICES_JSON_SHOP_DEV google-services.json für Dev (Base64)
GOOGLE_SERVICES_JSON_SHOP_PROD google-services.json für Prod (Base64)

iOS (zusätzlich)

Secret Beschreibung
IOS_DIST_CERTIFICATE_BASE64 Apple Distribution Certificate (.p12, Base64)
IOS_DIST_CERTIFICATE_PASSWORD Certificate Passwort
IOS_PROVISION_PROFILE_SHOP_BASE64 Provisioning Profile (Base64)
APP_STORE_CONNECT_API_KEY_ID ASC API Key ID
APP_STORE_CONNECT_API_ISSUER_ID ASC Issuer ID
APP_STORE_CONNECT_API_KEY_BASE64 ASC Private Key (.p8, Base64)

Optional

Secret Beschreibung
SHOP_APP_ICON_BASE64 Custom App Icon (überschreibt Default)

9. Client-Registry Format

Die .github/client-registry.json unterstützt optional ein Canary-Konzept:

Einfaches Format (alle gleichzeitig)

{
  "main": [
    "easysale-client-tech-schuppen",
    "easysale-client-gemuesebau-steiner"
  ]
}

Erweitertes Format (Canary zuerst) — optional, für später

{
  "main": {
    "canary": ["easysale-client-tech-schuppen"],
    "clients": ["easysale-client-gemuesebau-steiner"]
  }
}

10. Kompletter Flow: Schritt für Schritt

Alltag: Core-Bugfix

1. Du fixst einen Bug in core/shared oder core/apps
2. Du bumpst die Version: core/shared/pubspec.yaml → version: 1.0.8
3. Du pushst auf main

AUTOMATISCH (Core-Repo):
4. tag-release.yml      → Git-Tag v1.0.8
5. notify-clients.yml   → sendet { core_version: "v1.0.8" } an alle Clients

AUTOMATISCH (pro Client, z.B. tech-schuppen + gemuesebau-steiner):
6. auto-deploy.yml empfängt core-updated Event
7. Parallel Builds:
   ├─ Web Dev-Build      (~4 min, ubuntu)
   ├─ Web Prod-Build     (~4 min, ubuntu)
   ├─ Android Dev-Build  (~8 min, ubuntu)
   ├─ Android Prod-Build (~8 min, ubuntu)
   ├─ iOS Dev-Build      (~12 min, self-hosted macOS)
   └─ iOS Prod-Build     (~12 min, self-hosted macOS)
8. Deploy Dev:
   ├─ Web Dev → Firebase Dev
   ├─ Android Dev → Play Store Internal Testing
   └─ iOS Dev → TestFlight
9. pin-version: pubspec.yaml ref: v1.0.8 (committed)

DU: Testest Tech-Schuppen auf Dev
10. Web: https://dev-erp.tech-schuppen.easysale.de
11. Android: Internal Testing auf dem Handy
12. iOS: TestFlight auf dem iPhone

DU: Alles funktioniert!
13. GitHub → Core-Repo → Actions → "Promote All Clients"
    ODER: GitHub → Client-Repo → Actions → "Promote to Production"

AUTOMATISCH:
14. promote-to-prod.yml (pro Client):
    ├─ Web Prod-Artifact  → Firebase Prod     (kein Rebuild, ~60s)
    ├─ Android Prod-AAB   → Play Store Prod   (kein Rebuild, ~60s)
    └─ iOS Prod-IPA       → TestFlight Prod   (kein Rebuild, ~60s)
15. iOS: Manuell in App Store Connect für Review freigeben

Nur Client-Änderung (z.B. Branding, Feature-Flag)

1. Du änderst etwas im Client-Repo (z.B. Logo, Feature Override)
2. Du pushst auf main des Client-Repos

AUTOMATISCH:
3. auto-deploy.yml triggert (push-Event)
4. Baut mit der aktuell gepinnten Core-Version (z.B. v1.0.8)
5. Deployed auf Dev → Du testest → Promote auf Prod

Rollback

1. Problem auf Prod entdeckt!
2. Option A (schnell): Promote den VORHERIGEN erfolgreichen Build
   → promote-to-prod.yml → Run-ID des letzten guten Builds eingeben
3. Option B (sauber): Client pubspec.yaml → ref: v1.0.7 → push → auto-deploy

11. Migration vom IST-Zustand

Schritt 1: Core-Repo — Neue Workflows erstellen

  1. tag-release.yml erstellen
  2. client-build-web-reusable.yml erstellen
  3. client-build-android-reusable.yml erstellen
  4. client-build-ios-reusable.yml erstellen
  5. client-deploy-web-reusable.yml erstellen (ersetzt alten client-deploy-reusable.yml)
  6. client-deploy-android-reusable.yml erstellen
  7. client-deploy-ios-reusable.yml erstellen
  8. promote-all-clients.yml erstellen
  9. notify-clients.yml anpassen (core_version im Payload)
  10. client-registry.json anpassen (Tech-Schuppen hinzufügen)

Schritt 2: Tech-Schuppen als Client anlegen

  1. Repo easysale-client-tech-schuppen erstellen (via create_client.sh)
  2. Firebase Dev + Prod Projekte zuordnen
  3. Secrets konfigurieren
  4. auto-deploy.yml + promote-to-prod.yml einrichten

Schritt 3: Gemüsebau Steiner migrieren

  1. auto-deploy.yml durch neues Template ersetzen
  2. promote-to-prod.yml hinzufügen
  3. pubspec.yaml von ref: main auf aktuellen Tag umstellen
  4. FIREBASE_SA_DEV Secret hinzufügen

Schritt 4: Alte Workflows entfernen

  1. web-core-deployment.yml → deaktivieren (umbenennen zu .disabled)
  2. android-core-deployment.yml → deaktivieren
  3. ios-core-deployment.yml → deaktivieren
  4. web-clients-deployment.yml → löschen (bereits deprecated)
  5. client-deploy-reusable.yml (alt) → löschen nach Migration

Schritt 5: Onboarding-Templates aktualisieren

  1. onboarding/templates/client-auto-deploy.yml → neues Template
  2. onboarding/templates/promote-to-prod.yml → neues Template
  3. onboarding/create_client.sh → anpassen (neue Workflow-Templates kopieren)

12. Deployment-Setup für Clients

Architektur

Das Deployment-Setup ist in ein eigenständiges Script ausgelagert:

onboarding/
├── create_client.sh                ← Komplettes Client-Onboarding (ruft setup auf)
└── setup_client_deployment.sh      ← Deployment-Workflows einrichten (wiederholbar)

Warum ein separates Script?

create_client.sh setup_client_deployment.sh
Wann Einmalig bei Neuanlage Jederzeit
Was Firebase, Flutter, Secrets, etc. Nur CI/CD Workflows + Registry
Idempotent Nein Ja — kann beliebig oft laufen
Use Cases Neuer Client Bestehende Clients nachrüsten, Templates updaten

Verwendung

# Interaktiv — zeigt verfügbare Clients zur Auswahl
./onboarding/setup_client_deployment.sh

# Direkt mit Pfad
./onboarding/setup_client_deployment.sh ../easysale-client-gemuesebau-steiner

# Mit Core-Version pinnen
./onboarding/setup_client_deployment.sh ../easysale-client-gemuesebau-steiner --pin-version v1.2.3

# Nur Workflows updaten (kein Registry-Eintrag)
./onboarding/setup_client_deployment.sh ../easysale-client-gemuesebau-steiner --skip-registry

# ALLE registrierten Clients auf einmal updaten
./onboarding/setup_client_deployment.sh --all

# Vorschau — zeigt was passieren würde
./onboarding/setup_client_deployment.sh --all --dry-run

Was das Script macht

  1. Workflows installieren: 4 Templates → .github/workflows/
  2. auto-deploy.yml — Build + Deploy auf Dev (automatisch bei Core-Update)
  3. promote-to-prod.yml — Artifact → Prod (manuell oder Bulk)
  4. sync-prod-to-dev.yml — Daten Prod→Dev
  5. env-health-check.yml — Infra-Vergleich Dev vs Prod
  6. Core-Version pinnen: ref: mainref: v1.2.3 in pubspec.yaml
  7. Registry-Eintrag: Client in .github/client-registry.json registrieren
  8. Backup: Bestehende Workflows werden als .bak gesichert
  9. Diff-Check: Bereits aktuelle Workflows werden übersprungen

Integration mit create_client.sh

create_client.sh ruft automatisch setup_client_deployment.sh auf:

# In create_client.sh (create_client_structure Funktion):
bash "$SCRIPT_DIR/setup_client_deployment.sh" "$CLIENT_DIR" \
  --pin-version "$CORE_VERSION" \
  --skip-registry \    # Registry wird separat in finalize_github_repo() gemacht
  --skip-commit        # Commit passiert am Ende des Gesamtscripts

Was du nach dem Setup noch manuell tun musst

Pflicht: Web-Deployment

Schritt Wo Secret-Name
Firebase SA für Dev erstellen/kopieren GitHub → Client-Repo → Settings → Secrets FIREBASE_SA_DEV
Firebase SA für Prod erstellen/kopieren GitHub → Client-Repo → Settings → Secrets FIREBASE_SA_PROD
Sentry DSN eintragen GitHub → Client-Repo → Settings → Secrets SENTRY_DSN

Optional: Android-Deployment

Schritt Secret-Name
Keystore erstellen + Base64-kodieren ANDROID_KEYSTORE_SHOP_BASE64
Keystore-Passwort ANDROID_KEYSTORE_SHOP_PASSWORD
Key-Alias ANDROID_KEY_SHOP_ALIAS
Key-Passwort ANDROID_KEY_SHOP_PASSWORD
Google Play Service Account GOOGLE_PLAY_SERVICE_ACCOUNT_JSON
google-services.json Dev (Base64) GOOGLE_SERVICES_JSON_SHOP_DEV
google-services.json Prod (Base64) GOOGLE_SERVICES_JSON_SHOP_PROD

Optional: iOS-Deployment

Schritt Secret-Name
Distribution Certificate (.p12, Base64) IOS_DIST_CERTIFICATE_BASE64
Certificate-Passwort IOS_DIST_CERTIFICATE_PASSWORD
Provisioning Profile (Base64) IOS_PROVISION_PROFILE_SHOP_BASE64
App Store Connect API Key ID APP_STORE_CONNECT_API_KEY_ID
App Store Connect Issuer ID APP_STORE_CONNECT_API_ISSUER_ID
App Store Connect Private Key (Base64) APP_STORE_CONNECT_API_KEY_BASE64

Optional: Custom App Icon

Schritt Secret-Name
App Icon (Base64, base64 -i icon.png \| pbcopy) SHOP_APP_ICON_BASE64

Template-Dateien (Speicherorte)

onboarding/templates/
├── client-auto-deploy.yml      ← Wird zu .github/workflows/auto-deploy.yml
├── promote-to-prod.yml         ← Wird zu .github/workflows/promote-to-prod.yml
├── sync-prod-to-dev.yml        ← Wird zu .github/workflows/sync-prod-to-dev.yml
└── env-health-check.yml        ← Wird zu .github/workflows/env-health-check.yml

Änderungen an den Templates werden mit ./onboarding/setup_client_deployment.sh --all auf alle Clients ausgerollt.

Schnelltest nach Client-Setup

Nach dem Einrichten und Setzen der Secrets:

  1. Gehe ins Client-Repo → Actions → "Build & Deploy to Dev" → "Run workflow"
  2. Wähle platforms: web-only (für den ersten Test)
  3. Beobachte den Workflow — er sollte:
  4. Core-Version auflösen (latest Tag)
  5. Web Dev-Build + Prod-Build parallel erstellen
  6. Dev-Build auf Firebase Dev deployen
  7. Core-Version in pubspec.yaml pinnen
  8. Prüfe die Dev-URL des Clients
  9. Promote testen: Actions → "Promote to Production" → "Run workflow"

13. FAQ

Warum 2 Builds pro Plattform statt 1?

Die Firebase-Config wird per --dart-define=ENVIRONMENT=... zur Build-Zeit eingebrannt. Dev-Build spricht mit Dev-Firebase, Prod-Build mit Prod-Firebase. Beide entstehen aus exakt demselben Code-Stand (deterministisch).

Kostet das nicht doppelte CI-Minuten?

Ja, aber: Die Builds laufen parallel, nicht sequentiell. Die Gesamtdauer bleibt gleich. Die CI-Kosten steigen, aber du sparst dir Prod-Rebuilds bei der Promotion. Netto ist es oft günstiger weil Promotions häufiger sind als Builds.

Was passiert wenn ein Build fehlschlägt?

Nur der fehlgeschlagene Build/Deploy wird rot. Die anderen laufen weiter. Du kannst einzelne Plattformen manuell nachbauen (workflow_dispatch → platform: android-only).

Wie lange bleiben Artifacts?

30 Tage (konfigurierbar via retention-days). Danach muss neu gebaut werden.

Kann ich einzelne Clients promoten statt alle?

Ja. Gehe direkt ins Client-Repo → Actions → "Promote to Production". "Promote All Clients" ist nur ein Bulk-Trigger.

Brauche ich für jeden Kunden einen macOS Self-Hosted Runner?

Nein. Ein Runner reicht — die iOS-Builds laufen sequentiell in der Matrix. Bei vielen Clients kann das zum Flaschenhals werden, dann mehrere Runner einsetzen.

Was wenn ein Client auf einer älteren Core-Version bleiben soll?

Der Client pinnt einfach auf ref: v1.0.6 und ignoriert Notifications. Dazu: Client-Registry um Versionsfilter erweitern (Zukunft).