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¶
- Architektur-Überblick
- IST vs. SOLL
- Core-Repo Workflows
- Client-Repo Workflows (Templates)
- Reusable Workflows (Build)
- Reusable Workflows (Deploy)
- Environment-Handling bei Mobile
- Secrets pro Client-Repo
- Client-Registry Format
- Kompletter Flow: Schritt für Schritt
- Migration vom IST-Zustand
- Automatisches Deployment-Setup bei Client-Erstellung
- FAQ
1. Architektur-Überblick¶
Grundprinzipien¶
- Eine Pipeline für alle — Core hat KEINE eigenen Deployment-Workflows. Tech-Schuppen wird als regulärer Client behandelt (Canary).
- Artifact Promotion — Build einmal, deploye überall. Kein Rebuild bei Prod-Promotion (Web).
- Tag-basiertes Pinning — Clients referenzieren Core per Git-Tag (
ref: v1.0.7), nichtref: main. - 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
ENVIRONMENTper--dart-definebeim Build einbrennt. Für Web-Deployments müssen wir den Build mit dem korrektenENVIRONMENT-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 dasselbeENVIRONMENT-Flag brauchen.Korrektur: Da
--dart-define=ENVIRONMENT=developmentvsproductionden 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)¶
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¶
tag-release.ymlerstellenclient-build-web-reusable.ymlerstellenclient-build-android-reusable.ymlerstellenclient-build-ios-reusable.ymlerstellenclient-deploy-web-reusable.ymlerstellen (ersetzt altenclient-deploy-reusable.yml)client-deploy-android-reusable.ymlerstellenclient-deploy-ios-reusable.ymlerstellenpromote-all-clients.ymlerstellennotify-clients.ymlanpassen (core_version im Payload)client-registry.jsonanpassen (Tech-Schuppen hinzufügen)
Schritt 2: Tech-Schuppen als Client anlegen¶
- Repo
easysale-client-tech-schuppenerstellen (viacreate_client.sh) - Firebase Dev + Prod Projekte zuordnen
- Secrets konfigurieren
auto-deploy.yml+promote-to-prod.ymleinrichten
Schritt 3: Gemüsebau Steiner migrieren¶
auto-deploy.ymldurch neues Template ersetzenpromote-to-prod.ymlhinzufügenpubspec.yamlvonref: mainauf aktuellen Tag umstellenFIREBASE_SA_DEVSecret hinzufügen
Schritt 4: Alte Workflows entfernen¶
web-core-deployment.yml→ deaktivieren (umbenennen zu.disabled)android-core-deployment.yml→ deaktivierenios-core-deployment.yml→ deaktivierenweb-clients-deployment.yml→ löschen (bereits deprecated)client-deploy-reusable.yml(alt) → löschen nach Migration
Schritt 5: Onboarding-Templates aktualisieren¶
onboarding/templates/client-auto-deploy.yml→ neues Templateonboarding/templates/promote-to-prod.yml→ neues Templateonboarding/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¶
- Workflows installieren: 4 Templates →
.github/workflows/ auto-deploy.yml— Build + Deploy auf Dev (automatisch bei Core-Update)promote-to-prod.yml— Artifact → Prod (manuell oder Bulk)sync-prod-to-dev.yml— Daten Prod→Devenv-health-check.yml— Infra-Vergleich Dev vs Prod- Core-Version pinnen:
ref: main→ref: v1.2.3in pubspec.yaml - Registry-Eintrag: Client in
.github/client-registry.jsonregistrieren - Backup: Bestehende Workflows werden als
.bakgesichert - 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:
- Gehe ins Client-Repo → Actions → "Build & Deploy to Dev" → "Run workflow"
- Wähle
platforms: web-only(für den ersten Test) - Beobachte den Workflow — er sollte:
- Core-Version auflösen (latest Tag)
- Web Dev-Build + Prod-Build parallel erstellen
- Dev-Build auf Firebase Dev deployen
- Core-Version in pubspec.yaml pinnen
- Prüfe die Dev-URL des Clients
- 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).