كيف أتمتت نشر تطبيق Flutter الخاص بي على 6 متاجر باستخدام GitHub Actions

كيف أتمتت نشر تطبيق Flutter الخاص بي على 6 متاجر باستخدام GitHub Actions

وسم git واحد يبني Android و iOS و macOS و Windows و Linux و Huawei — ويرفع كل ملف إلى متجره. دليل شامل لخط الإصدار، مع كل عقبات التوقيع وواجهات الـ API التي واجهتها.

10 دقائق للقراءة
تم التحديث ١٢ يونيو ٢٠٢٦

تطبيق Flutter واحد يمكنه استهداف ست منصات من قاعدة كود واحدة. المكافأة هي الانتشار؛ الضريبة هي عمل الإصدار. نشر هدى، تطبيق إسلامي شامل، عنى الشحن إلى Google Play و App Store و Mac App Store و Microsoft Store و Snapcraft Store و Huawei AppGallery — لكل منها نموذج توقيع خاص، وواجهة رفع خاصة، وطريقة خاصة في الفشل.

القيام بذلك يدوياً، ست مرات، مع كل إصدار، ليس خطة. لذا بنيت خط GitHub Actions واحداً: أدفع وسم git واحداً، وبعد دقائق يكون البناء الجديد قد رُفع إلى المتاجر الستة جميعها.

يستعرض هذا المقال هذا الخط من البداية إلى النهاية — البنية، وإعداد التوقيع لكل منصة، والعقبات التي واجهتها (خصوصاً العقبتين اللتين لا تخبرك بهما Huawei). إذا كنت تصون تطبيق Flutter متعدد المنصات، يمكنك أخذ معظم هذا مباشرة.

شكل الخط

كل شيء عبارة عن ملف workflow واحد يُشغَّل بوسم إصدار:

.github/workflows/release.yml
on:
  push:
    tags:
      - 'v*'
  workflow_dispatch:
    inputs:
      dry_run:
        description: 'Build only — skip store uploads'
        type: boolean
        default: false

قراران تصميميان مهمان هنا:

  1. مُشغَّل بالوسم. الإصدارات فعل متعمَّد — git tag v3.4.0 && git push --tags. لا شيء يُنشر مع التزام عادي.
  2. مخرج طوارئ dry_run. كل خطوة رفع محمية بـ if: ${{ !inputs.dry_run }}. تشغيل الخط يدوياً مع تفعيل الخيار يبني المنصات الست ولا يرفع شيئاً — لا يُقدَّر بثمن للتحقق من بناء ناجح دون استهلاك تقديم متجر.

كل منصة هي مهمة مستقلة، فتعمل بالتوازي وفشل Windows لا يعيق رفع iOS:

jobs:
  build-android:   # AAB → Google Play
  build-ios:       # IPA → App Store
  build-macos:     # pkg → Mac App Store
  build-windows:   # MSIX → Microsoft Store
  build-linux:     # snap → Snapcraft
  upload-appgallery:  # AAB → Huawei (needs: build-android)
  create-release:     # جمع كل الملفات → GitHub Release

ثبّت إجراءاتك بـ SHA

كل إجراء طرف ثالث مثبَّت بـ SHA كامل للالتزام، لا بوسم متحرك:

- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

وسم مثل @v4 يمكن نقله بالقوة ليشير لكود جديد؛ الـ SHA لا يمكن. عندما يحمل الـ workflow مفاتيح توقيعك وبيانات اعتماد متاجرك، فإن سلامة سلسلة الإمداد ليست اختيارية — ثبّت كل شيء.

المقتطفات في هذا المقال تستخدم وسوماً متحركة (@v1 و @v4) لسهولة القراءة. في الـ workflow الفعلي، كل واحد منها مثبَّت بـ SHA كامل للالتزام مع رقم النسخة في تعليق لاحق، تماماً كما هو موضح أعلاه.

السر الذي تحتاجه كل مهمة

تحتفظ هدى بمفاتيح API في lib/core/keys/hadith_key.dart، وهو متجاهَل في git. لا يستطيع CI التجميع بدونه، فأول خطوة في كل مهمة تعيد إنشاءه من سر base64:

- name: Create API keys file
  env:
    DART_KEYS_FILE: ${{ secrets.DART_KEYS_FILE }}
  run: |
    mkdir -p lib/core/keys
    printf '%s' "$DART_KEYS_FILE" | base64 --decode > lib/core/keys/hadith_key.dart

هذا النمط — فك ترميز سر base64 إلى ملف — يتكرر باستمرار عبر الخط: مخازن المفاتيح، الشهادات، مفاتيح API. أي شيء ثنائي أو متعدد الأسطر يصبح نص base64 في GitHub Secrets ويُفك وقت التشغيل.

Android ← Google Play

Android هو الألطف بين الستة. فك مخزن المفاتيح، اكتب key.properties، ابنِ، ارفع.

- name: Decode Android keystore
  env:
    ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
  run: printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > android/app/keystore.jks
 
- name: Create key.properties
  env:
    ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
    ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
    ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
  run: |
    cat > android/key.properties <<EOF
    storePassword=$ANDROID_STORE_PASSWORD
    keyPassword=$ANDROID_KEY_PASSWORD
    keyAlias=$ANDROID_KEY_ALIAS
    storeFile=keystore.jks
    EOF

الرفع نفسه إجراء مجتمعي مصان جيداً. يريد متجر Play ملف AAB (يُبنى الـ APK فقط كأصل لـ GitHub Release لمن يثبّت يدوياً):

- name: Upload to Google Play
  if: ${{ !inputs.dry_run }}
  uses: r0adkll/upload-google-play@v1
  with:
    serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
    packageName: com.aw.huda
    releaseFiles: build/app/outputs/bundle/release/app-release.aab
    track: production
    status: completed
    whatsNewDirectory: distribution/whatsnew

يشير whatsNewDirectory إلى مجلد من ملفات whatsnew-<locale> — نفس ملاحظات الإصدار تُعاد استخدامها لاحقاً مع Huawei، فتعيش في مكان واحد.

iOS ← App Store

توقيع iOS هو حيث تغرق معظم الخطوط في إدارة ملفات التزويد (provisioning profiles). الحيلة التي تتجنب كل ذلك: دع Xcode يدير الملفات بنفسه، موثَّقاً بمفتاح App Store Connect API.

- name: Archive iOS app
  run: |
    cd ios
    xcodebuild archive \
      -workspace Runner.xcworkspace \
      -scheme Runner \
      -configuration Release \
      -archivePath $RUNNER_TEMP/Runner.xcarchive \
      -destination "generic/platform=iOS" \
      -allowProvisioningUpdates \
      -authenticationKeyPath ~/.private_keys/AuthKey_${ASC_KEY_ID}.p8 \
      -authenticationKeyID "$ASC_KEY_ID" \
      -authenticationKeyIssuerID "$ASC_ISSUER_ID"

علم -allowProvisioningUpdates مع مفتاح API يعني عدم وجود أسرار ملفات تزويد على الإطلاق — ينشئ Xcode ويُنزّل ما يحتاجه فوراً. هذا يحذف فئة كاملة من الأسرار منتهية الصلاحية وصعبة التدوير.

لكن الشهادات لا تزال يجب أن تعيش في keychain. تنشئ المهمة keychain مؤقتاً بكلمة مرور عشوائية، تستورد ملفات .p12 للتطوير والتوزيع، وتهدمها بعد ذلك:

- name: Import certificates
  run: |
    KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
    KEYCHAIN_PASSWORD=$(openssl rand -hex 32)
    security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
    security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
    security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
    echo "$DIST_CERT_BASE64" | base64 --decode > "$RUNNER_TEMP/dist.p12"
    security import "$RUNNER_TEMP/dist.p12" -P "$DIST_CERT_PASSWORD" \
      -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
    security set-key-partition-list -S apple-tool:,apple: \
      -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
    security list-keychain -d user -s "$KEYCHAIN_PATH"
 
# ... ونظّف دائماً، حتى عند الفشل:
- name: Cleanup keychain
  if: always()
  run: security delete-keychain "$KEYCHAIN_PATH" 2>/dev/null || true

التصدير والرفع يتولاهما Fastlane (fastlane install_profiles ثم fastlane deploy)، الكل موثَّق بنفس مفتاح API. مفتاح App Store Connect واحد يوقّع ويصدّر ويرفع.

ملاحظة عن المُشغِّلات: مهام iOS و macOS تعمل على macos-26. منذ أبريل 2026 تتطلب Apple البناء مقابل SDK نسخة 26 لـ iOS/macOS، الذي يأتي مع Xcode 26 — فصورة المُشغِّل مثبَّتة، لا متروكة على macos-latest.

macOS ← Mac App Store

macOS هو iOS مع تجعيدتين إضافيتين. أولاً، يريد Mac App Store ملف .pkg مثبِّت موقَّع، يحتاج شهادة ثانية — هوية "3rd Party Mac Developer Installer" — تُستخدم مع productbuild:

- name: Create installer pkg
  env:
    MACOS_INSTALLER_IDENTITY: ${{ secrets.MACOS_INSTALLER_IDENTITY }}
  run: |
    APP_PATH="build/macos/Build/Products/Release/huda.app"
    productbuild \
      --sign "$MACOS_INSTALLER_IDENTITY" \
      --component "$APP_PATH" /Applications \
      build/macos/Huda-macOS.pkg

ثانياً، لأن تطبيق macOS يستخدم صلاحيات (sandbox، الشبكة، الموقع، الصوت)، يحتاج ملف تزويد حقيقياً. تفكّه المهمة، تقرأ UUID الخاص به بـ PlistBuddy، وتضعه حيث يبحث Xcode عن الملفات:

UUID=$(/usr/libexec/PlistBuddy -c "Print UUID" /dev/stdin \
  <<< "$(security cms -D -i "$RUNNER_TEMP/macos.provisionprofile")")
cp "$RUNNER_TEMP/macos.provisionprofile" \
  ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".provisionprofile

من هناك، يرفع fastlane deploy الـ pkg الموقَّع بنفس مفتاح App Store Connect.

Windows ← Microsoft Store

تنتج مهمة Windows ملف MSIX وتدفعه بأداة msstore CLI من Microsoft. تفصيلان أثبتا جدارتهما هنا.

أولاً، ترقيم MSIX صارم — يتطلب نسخة رباعية x.y.z.0، ويجب أن تطابق قائمة المتجر. بدلاً من تحرير pubspec.yaml يدوياً، يشتقها الـ workflow:

- name: Sync MSIX version from pubspec
  shell: pwsh
  run: |
    $content = Get-Content pubspec.yaml -Raw
    if ($content -match 'version:\s+(\d+\.\d+\.\d+)') {
      $version = $Matches[1] + ".0"
      $content = $content -replace 'msix_version:\s+\S+', "msix_version: $version"
      Set-Content pubspec.yaml $content
    }

ثانياً — وهذا الجزء الذي تخطئ فيه معظم الأدلة — msstore reconfigure لا يجب أن يكون تفاعلياً. موثَّق كتدفق تسجيل دخول بالمتصفح ينتهي بمهلة في CI، لكن إذا مرّرت بيانات اعتماد Entra كأعلام (flags)، يكتبها لإعدادات الأداة بصمت ويعمل publish اللاحق ببساطة:

- name: Upload to Microsoft Store
  shell: pwsh
  env:
    AZURE_AD_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }}
    AZURE_AD_CLIENT_ID: ${{ secrets.AZURE_AD_CLIENT_ID }}
    AZURE_AD_CLIENT_SECRET: ${{ secrets.AZURE_AD_CLIENT_SECRET }}
    MS_STORE_SELLER_ID: ${{ secrets.MS_STORE_SELLER_ID }}
    MS_STORE_APP_ID: ${{ secrets.MS_STORE_APP_ID }}
  run: |
    msstore reconfigure `
      --tenantId "$env:AZURE_AD_TENANT_ID" `
      --clientId "$env:AZURE_AD_CLIENT_ID" `
      --clientSecret "$env:AZURE_AD_CLIENT_SECRET" `
      --sellerId "$env:MS_STORE_SELLER_ID"
 
    $msix = Get-ChildItem -Path build -Recurse -Filter "*.msix" | Select-Object -First 1
    msstore publish "$($msix.FullName)" --appId "$env:MS_STORE_APP_ID" -v

إعداد مستأجر Entra ID وتسجيل التطبيق وربط Partner Center هو الجزء المتعب فعلاً — يفرض عليك تدفق Microsoft للحسابات الشخصية المرور عبر "Default Directory" في Azure ومستخدم مسؤول بنطاق .onmicrosoft.com. هذا موثَّق خطوة بخطوة في دليل أسرار المشروع؛ خصّص له بعد ظهيرة أول مرة.

Linux ← Snapcraft

بعد ملحمة جعل هدى تُبنى كحزمة Snap (قصة رويتها في مقال منفصل)، أتمتة النشر قصيرة بشكل منعش. توفّر Snapcraft إجراءات رسمية:

- uses: snapcore/action-build@v1
  id: snapcraft
 
- name: Upload to Snapcraft Store
  if: ${{ !inputs.dry_run }}
  uses: snapcore/action-publish@v1
  env:
    SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
  with:
    snap: ${{ steps.snapcraft.outputs.snap }}
    release: stable

اللمسة اليدوية الوحيدة هي مزامنة النسخة من pubspec.yaml إلى snapcraft.yaml بـ sed، نفس فكرة خطوة MSIX.

Huawei AppGallery ← الذي يقاوم

هنا خسرت أكثر وقت، فدعني أوفّر عليك نفس الساعات.

ليس لـ AppGallery إجراء رفع رسمي ولا إضافة Fastlane تعمل بموثوقية. لديها واجهة Publishing REST API — لكن للواجهة فخ. تدفعك AppGallery Connect الآن نحو إنشاء Service Account (مفتاح JSON، تماماً مثل Google). إذا حاولت توثيق واجهة Publishing بمفتاح JSON ذاك، تحصل على:

client token auth failed

واجهة Publishing فقط تقبل API client على مستوى الفريق — زوج client_id + client_secret بسيط، يُنشأ تحت تبويب مختلف في الكونسول. مفتاح Service Account الأحدث يُرفض بصمت. الكونسول يدفعك نحو الشيء الذي لا يعمل.

بمجرد امتلاك بيانات الاعتماد الصحيحة، الرفع رقصة من أربع نداءات، لففتها في سكربت Python صغير:

distribution/appgallery_upload.py
# 1. مبادلة client_id/secret برمز وصول
# 2. GET لرابط رفع + authCode
# 3. POST لملف AAB كـ multipart إلى ذلك الرابط
# 4. PUT لـ app-file-info لربط الملف المرفوع بالتطبيق

الفشل غير البديهي بين الخطوتين 3 و 4 والتقديم النهائي. تُجمّع Huawei ملف AAB من جانب الخادم بعد ربطه، وإذا قدّمت للمراجعة قبل انتهاء ذلك، يفشل التقديم. لا يوجد webhook لـ "اكتمل التجميع"، فالحل العملي انتظار متعمَّد:

if not args.no_submit:
    print("… waiting 90s for AAB server-side compilation")
    time.sleep(90)

ملاحظات الإصدار نداء API منفصل لكل لغة. يعيد السكربت استخدام نفس ملفات whatsnew-<locale> التي يستهلكها Google Play، بربط كل اسم ملف برموز لغات Huawei:

LOCALE_MAP = {
    "whatsnew-en-US": "en_US",
    "whatsnew-ar":    "ar",
    "whatsnew-de-DE": "de_DE",
    # ...
}
for filepath in sorted(glob.glob(os.path.join(args.whatsnew, "whatsnew-*"))):
    lang = LOCALE_MAP.get(os.path.basename(filepath))
    requests.put(f"{BASE}/app-language-info?appId={app_id}",
                 headers=auth_headers,
                 json={"lang": lang, "newFeatures": text})

في الـ workflow، تنتظر مهمة AppGallery ببساطة بناء Android، تسحب أصل AAB، وتشغّل السكربت:

upload-appgallery:
  needs: build-android
  if: ${{ !inputs.dry_run }}
  steps:
    - uses: actions/download-artifact@v4
      with:
        name: android-aab
    - name: Upload to AppGallery
      env:
        HUAWEI_CLIENT_ID: ${{ secrets.HUAWEI_CLIENT_ID }}
        HUAWEI_CLIENT_SECRET: ${{ secrets.HUAWEI_CLIENT_SECRET }}
        HUAWEI_APP_ID: ${{ secrets.HUAWEI_APP_ID }}
      run: |
        pip install requests
        python3 distribution/appgallery_upload.py --aab app-release.aab

إعادة استخدام الـ AAB المبني مسبقاً بدلاً من إعادة بنائه تُبقي البناء الذي يُشحن إلى Huawei مطابقاً بايت ببايت لذلك الموجود على Google Play.

جمع كل شيء في GitHub Release

المهمة الأخيرة تعتمد على كل مهام البناء الخمس، تُنزّل كل أصل، تعيد تسميتها برقم النسخة، وتُصدر GitHub Release — فحتى المستخدمون الذين لا يستعملون أي متجر يمكنهم أخذ ملف:

create-release:
  needs: [build-android, build-ios, build-macos, build-windows, build-linux]
  if: ${{ !cancelled() && startsWith(github.ref, 'refs/tags/') }}
  steps:
    - uses: actions/download-artifact@v4
      with:
        path: release-artifacts
    - name: Prepare release assets
      run: |
        VERSION=${GITHUB_REF_NAME#v}
        cp release-artifacts/android-apk/app-release.apk "release-assets/Huda-${VERSION}-android.apk"
        # ...نفس الشيء لـ msix و pkg و snap و ipa

لاحظ if: ${{ !cancelled() }} بدلاً من "نجح الكل" الضمني — لا يزال الإصدار يُصدر إذا فشل بناء متجر واحد، فمُشغِّل Windows متقلب لا يحرم الجميع من البقية.

كل شيء، من أمر واحد

مع وجود كل سر في مكانه، الإصدار ثلاثة أسطر:

# ارفع النسخة في pubspec.yaml، التزم، ثم:
git tag v3.4.0
git push origin master --tags

يتفرّع الخط إلى ست منصات بالتوازي، يوقّع كلاً منها بشكل صحيح، يرفع للمتاجر الست، وينشر GitHub Release بكل الملفات مرفقة. ما كان بعد ظهيرة من التصديرات اليدوية وست لوحات تحكم منفصلة صار الآن git push.

النقاط الرئيسية

  1. وسم واحد، خط واحد. شغّل الإصدارات على وسوم v*، لا على الالتزامات، وامنح نفسك وضع dry_run لاختبار البناء دون لمس أي متجر.

  2. base64 هو صيغة السر العالمية. مخازن المفاتيح، شهادات .p12، مفاتيح API — أي شيء ثنائي أو متعدد الأسطر يصبح سر base64 في GitHub ويُفك إلى ملف وقت التشغيل.

  3. دع مفتاح App Store Connect API يقوم بالعمل. xcodebuild -allowProvisioningUpdates مع مفتاح API يُلغي أسرار ملفات التزويد تماماً على iOS.

  4. أنشئ keychains مؤقتة ونظّفها دائماً. استخدم if: always() كي لا يترك بناء فاشل أبداً مواد توقيع على المُشغِّل.

  5. msstore reconfigure يعمل في CI إذا مرّرت بيانات الاعتماد كأعلام بدلاً من الاعتماد على تسجيل الدخول التفاعلي.

  6. واجهة Publishing من Huawei تحتاج API client، لا Service Account. مفتاح JSON الذي يدفعك إليه الكونسول مرفوض. واحسب وقت تجميع من جانب الخادم قبل التقديم.

  7. ابنِ مرة، أعد استخدام الأصل. تحصل Huawei على نفس AAB الذي يحصل عليه Google Play بتنزيل أصل build-android، لا بإعادة البناء.

  8. ثبّت الإجراءات بـ SHA. خط يحمل بيانات اعتماد متاجرك هدف لسلسلة الإمداد. ثبّت كل إجراء طرف ثالث بـ hash التزام كامل.


الأسئلة الشائعة

هل يمكن لـ workflow واحد في GitHub Actions نشر تطبيق Flutter على كل المتاجر؟

نعم. شغّل كل منصة كمهمة متوازية مستقلة في workflow واحد يُشغَّل بوسم إصدار. كل مهمة تبني ملف منصتها، توقّعه بأسرار مفكوكة من GitHub Secrets، وترفعه للمتجر المقابل — Google Play و App Store و Mac App Store و Microsoft Store و Snapcraft و Huawei AppGallery — كل ذلك من git push --tags واحد.

كيف أوقّع بناء iOS في CI دون إدارة ملفات التزويد؟

استخدم xcodebuild -allowProvisioningUpdates مع مفتاح App Store Connect API (ملف .p8). ينشئ Xcode ويُنزّل ملفات التزويد اللازمة تلقائياً وقت البناء، فتحتاج فقط لاستيراد شهادة التوقيع إلى keychain مؤقت — دون أسرار ملفات تزويد.

لماذا ترفض واجهة Publishing في Huawei AppGallery بيانات اعتمادي؟

تقبل واجهة Publishing فقط API client على مستوى الفريق (client_id + client_secret)، يُنشأ تحت تبويب "API client" في AppGallery Connect. مفتاح Service Account الأحدث بصيغة JSON — الذي يروّج له الكونسول بنشاط — ترفضه واجهة Publishing بخطأ client token auth failed. استخدم API client بدلاً منه.

كيف أرفع MSIX إلى Microsoft Store في خط بلا واجهة؟

استخدم أداة msstore CLI. رغم أن msstore reconfigure موثَّق كتسجيل دخول تفاعلي، فإن تمرير بيانات اعتماد Microsoft Entra ID كأعلام (--tenantId و --clientId و --clientSecret و --sellerId) يُهيئها بلا تفاعل. ثم شغّل msstore publish <مسار-msix> --appId <store-id>.

هل أعيد بناء تطبيق Android لكل متجر، أم أعيد استخدام بناء واحد؟

أعد استخدام بناء واحد. ابنِ AAB مرة في مهمة Android، ارفعه كأصل workflow، واجعل مهمة Huawei تُنزّل نفس الأصل. هذا يضمن أن الملف على Huawei AppGallery مطابق بايت ببايت لذلك على Google Play ويوفّر إعادة بناء كاملة.

شكل الخط