دمج GitHub Actions مع مشاريع .NET لأتمتة CI/CD
مقدمة: لماذا أصبحت أتمتة مشاريع .NET ضرورة وليست رفاهية؟
في بداية أي مشروع .NET تقريبًا، تبدو عملية النشر بسيطة جدًا. تفتح Visual Studio أو Rider، تعدّل الكود، تشغّل المشروع على جهازك، تتأكد أن كل شيء يعمل، ثم تقوم بإنشاء نسخة Production وتنسخ الملفات إلى الخادم. ربما تكون هذه الطريقة مقبولة عندما يكون المشروع صغيرًا جدًا، وعندما تكون أنت المطور الوحيد الذي يعرف كل تفاصيله، وعندما يحدث النشر مرة واحدة كل عدة أسابيع. لكن المشكلة تبدأ عندما يكبر المشروع، أو ينضم مطور آخر إلى الفريق، أو يصبح لديك أكثر من بيئة مثل Development وStaging وProduction، أو عندما يصبح النشر متكررًا لدرجة أن القيام به يدويًا يبدأ في استهلاك وقتك وتركيزك.
وهنا تظهر فكرة CI/CD، وهي واحدة من أهم الأفكار في عالم DevOps الحديث. بدلًا من أن تعتمد على الذاكرة والخطوات اليدوية، تجعل مستودع GitHub قادرًا على تنفيذ سلسلة من الخطوات بشكل تلقائي عند حدوث أحداث محددة، مثل إنشاء Pull Request أو تنفيذ Push على فرع معين. يستطيع النظام استرجاع المشروع، تثبيت إصدار .NET المطلوب، استعادة الحزم، بناء المشروع، تشغيل الاختبارات، إنشاء ملفات النشر، بناء Docker Image، رفعها إلى Registry، ثم نشر التطبيق إلى البيئة المناسبة. GitHub Actions مصممة أصلًا لأتمتة CI/CD داخل مستودعات GitHub، وتدعم بناء واختبار ونشر تطبيقات .NET باستخدام ملفات Workflow موجودة داخل .github/workflows.
الفكرة الجميلة هنا ليست فقط أنك "توفر وقتًا". الفائدة الحقيقية أنك تحول عملية النشر من سلسلة من التصرفات البشرية القابلة للخطأ إلى عملية قابلة للتكرار. عندما ينجح Workflow اليوم، يمكنه أن ينجح غدًا بالطريقة نفسها، ومع نفس أوامر البناء والاختبار والنشر. وإذا فشل، تحصل على سجل واضح يوضح في أي خطوة حدثت المشكلة.
ومن تجربتي مع مشاريع البرمجة، هناك فرق كبير بين قولك: "أنا متأكد أنني نفذت خطوات النشر بشكل صحيح"، وبين أن يكون لديك Workflow يقول لك فعليًا: "تم استرجاع المشروع، تم بناء Release، نجحت 187 اختبارات، تم إنشاء الحزمة، وتم نشر الإصدار". في الحالة الثانية أنت لا تعتمد على الذاكرة، بل تعتمد على نظام يمكنه توثيق العملية وإعادة تنفيذها.
ما المقصود بـ CI/CD في مشاريع .NET؟
قبل أن نكتب أول Workflow، من المهم فهم المصطلحين CI وCD لأنهما ليسا مجرد اختصارين يتم وضعهما في عنوان مقال عن GitHub Actions.
CI تعني Continuous Integration أو التكامل المستمر. الفكرة الأساسية هي أن تغييرات المطورين يتم دمجها باستمرار مع المشروع الرئيسي، ويتم التحقق منها آليًا. عندما يرسل المطور Pull Request، يمكن تشغيل dotnet restore ثم dotnet build ثم dotnet test. إذا فشل البناء أو فشل أحد الاختبارات، يعرف الفريق أن التغيير يحتاج إلى إصلاح قبل دمجه.
أما CD فيمكن أن تعني Continuous Delivery أو Continuous Deployment حسب طريقة العمل. في Continuous Delivery يتم تجهيز التطبيق للنشر بشكل مستمر، لكن قد تكون هناك خطوة موافقة بشرية قبل Production. أما Continuous Deployment فيذهب خطوة أبعد، بحيث يمكن أن ينتقل الإصدار إلى Production تلقائيًا بمجرد نجاح جميع الشروط.
في مشروع ASP.NET Core، يمكن أن يكون المسار النموذجي كالتالي:
Developer
|
| git push
v
GitHub Repository
|
v
Pull Request / Push
|
v
GitHub Actions
|
+--> Restore
|
+--> Build
|
+--> Unit Tests
|
+--> Integration Tests
|
+--> Security / Quality Checks
|
+--> Publish
|
+--> Package / Docker Image
|
v
Staging
|
| approval
v
Production
هذا المسار يمكن أن يكون أبسط أو أكثر تعقيدًا حسب المشروع. ليس المطلوب أن تبدأ بأكبر Pipeline ممكنة. الأفضل أن تبدأ ببناء واختبار المشروع، ثم تضيف النشر، وبعدها تضيف Docker أو Kubernetes أو Azure أو أي بنية أخرى تحتاجها.
لماذا GitHub Actions مناسب لمشاريع .NET؟
الميزة الأساسية هي أن GitHub Actions يعيش داخل البيئة التي يوجد فيها الكود أصلًا. إذا كان مشروعك موجودًا على GitHub، فلست مضطرًا بالضرورة إلى إنشاء نظام CI منفصل فقط لكي تقوم بعملية Build وTest. تستطيع وضع ملف YAML في مستودعك، وتحديد متى يعمل، وما هي Jobs التي ينفذها، وما هي الأدوات التي يحتاج إليها.
تدعم GitHub Actions مفهوم Workflow، وهو ملف YAML يصف عملية الأتمتة. توضع ملفات Workflow داخل:
.github/workflows/
وهذه نقطة مهمة جدًا؛ GitHub يتوقع ملفات Workflow في هذا المسار.
يمكن أن يحتوي المشروع مثلًا على:
MyShop/
├── MyShop.sln
├── src/
│ ├── MyShop.Api/
│ ├── MyShop.Application/
│ ├── MyShop.Domain/
│ └── MyShop.Infrastructure/
├── tests/
│ ├── MyShop.UnitTests/
│ └── MyShop.IntegrationTests/
├── Dockerfile
├── global.json
└── .github/
└── workflows/
├── ci.yml
└── cd.yml
هذا التنظيم يجعل GitHub Actions جزءًا من المشروع نفسه. وعندما يقوم مطور آخر باستنساخ المشروع، فإنه يستطيع رؤية قواعد CI/CD مباشرة بدل البحث عن مستند خارجي يخبره كيف تتم عملية البناء.
تجهيز مشروع .NET قبل إنشاء Pipeline
قبل أن نكتب Workflow، يجب أن نتأكد من أن مشروع .NET نفسه منظم بطريقة جيدة. لا يوجد GitHub Actions سحري يستطيع إنقاذ مشروع لا يمكن بناؤه من سطر الأوامر.
جرب أولًا على جهازك:
dotnet restore
ثم:
dotnet build --configuration Release --no-restore
ثم:
dotnet test --configuration Release --no-build
ثم إذا كان لديك ASP.NET Core:
dotnet publish src/MyShop.Api/MyShop.Api.csproj \
--configuration Release \
--output ./publish
الفكرة هنا بسيطة جدًا: إذا كانت هذه الأوامر تعمل على جهازك، فلدينا أساس جيد لتحويلها إلى خطوات داخل GitHub Actions.
وهذه نقطة عملية مهمة جدًا. أحيانًا يبدأ المطور مباشرة في كتابة YAML طويل جدًا، ثم يكتشف أن المشكلة ليست في GitHub Actions أصلًا، وإنما في المشروع نفسه. لذلك اجعل CLI هو نقطة الحقيقة الأساسية. إذا كان المشروع يمكن بناؤه واختباره من Terminal، يصبح نقله إلى CI أسهل بكثير.
تحديد إصدار .NET بشكل صريح
من الأخطاء التي قد تسبب مشاكل مزعجة في CI أن تعتمد على إصدار SDK الموجود في Runner دون أن تحدد الإصدار الذي يحتاجه المشروع.
يمكن استخدام ملف global.json لتحديد SDK المطلوب:
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestPatch"
}
}
وفي Workflow يمكنك أيضًا استخدام actions/setup-dotnet لتثبيت SDK المطلوب. الإصدار الحالي من Action يدعم تحديد إصدارات مثل 8.0.x أو 9.0.x أو إصدارات محددة بدقة، كما يمكن استخدام أكثر من إصدار عند الحاجة.
مثال:
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
إذا كان مشروعك يستخدم إصدارًا مختلفًا، استبدل القيمة بما يناسب مشروعك. والأفضل في المشاريع الاحترافية أن يكون الإصدار متوافقًا بوضوح بين global.json وبيئة CI.
إنشاء أول GitHub Actions Workflow لمشروع .NET
لنبدأ بأبسط Workflow ممكن. أنشئ:
.github/workflows/ci.yml
ثم:
name: .NET CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --configuration Release --no-build
هذا Workflow صغير، لكنه يحقق شيئًا مهمًا جدًا: يمنع دمج تغييرات لا يمكن بناؤها أو تفشل اختبارات المشروع.
عند حدوث Push إلى main، يعمل Workflow. وعند فتح Pull Request باتجاه main، يعمل أيضًا. GitHub Actions يوفر نموذجًا مشابهًا لبناء واختبار مشاريع .NET، ويمكنك توسيعه بحسب احتياجات المشروع.
فهم مكونات Workflow
من المهم ألا تتعامل مع YAML كأنه نص تحفظه فقط. افهم مكوناته.
السطر:
name: .NET CI
يعطي اسمًا للـ Workflow.
أما:
on:
فيحدد الأحداث التي تؤدي إلى تشغيله.
مثلًا:
on:
push:
branches:
- main
يعني أن Workflow يعمل عند Push إلى main.
بينما:
pull_request:
branches:
- main
يعني أنه يعمل عندما يكون هناك Pull Request يستهدف main.
ثم لدينا:
jobs:
وهي مجموعة المهام التي سيقوم بها Workflow.
داخلها:
build-and-test:
هو معرف Job.
ثم:
runs-on: ubuntu-latest
يحدد Runner الذي سينفذ المهمة.
وبعد ذلك:
steps:
وهي الخطوات الفعلية.
كل خطوة يمكن أن تستخدم Action جاهزًا:
uses: actions/checkout@v6
أو تنفذ أمرًا مباشرًا:
run: dotnet test
Microsoft توضح أن Workflow يتكون من Jobs وSteps، ويمكن تركيب Actions متعددة لتنفيذ عملية متكاملة.
لماذا نستخدم actions/checkout؟
قبل أن ينفذ Runner أمر:
dotnet build
يحتاج إلى الحصول على ملفات المشروع. Action:
- uses: actions/checkout@v6
يقوم باسترجاع محتوى المستودع إلى مساحة العمل الخاصة بالـ Runner.
بدونه لن يكون الكود موجودًا بالطريقة التي تتوقعها خطوات البناء.
ولهذا غالبًا ما تكون أول خطوة:
- name: Checkout
uses: actions/checkout@v6
ثم تأتي عملية إعداد .NET.
Restore ثم Build ثم Test
من الأفضل فهم العلاقة بين هذه الأوامر.
dotnet restore يستعيد Dependencies الخاصة بالمشروع.
dotnet restore
بعدها:
dotnet build --configuration Release --no-restore
يقوم ببناء المشروع، مع --no-restore حتى لا يعيد Restore إذا تم بالفعل.
ثم:
dotnet test --configuration Release --no-build
يشغل الاختبارات دون إعادة بناء المشروع إذا كان البناء قد تم في الخطوة السابقة.
يمكنك بالطبع تنفيذ:
dotnet test
مباشرة، لكن فصل الخطوات يجعل Pipeline أكثر وضوحًا ويساعدك على معرفة أين حدث الفشل.
إذا فشل Restore، فالمشكلة في الحزم أو NuGet.
إذا فشل Build، فالمشكلة في compilation أو configuration.
إذا فشل Test، فالكود قد يبني بشكل صحيح لكنه لا يحقق السلوك المتوقع.
هذه التفاصيل الصغيرة تصبح مهمة جدًا عندما يكون لديك Pipeline يستغرق عدة دقائق.
تشغيل الاختبارات مع Coverage
في المشاريع الجادة، لا يكفي أن تعرف أن الاختبارات نجحت. من المفيد أيضًا استخراج Code Coverage.
يمكنك استخدام:
dotnet test \
--configuration Release \
--collect:"XPlat Code Coverage"
ثم يمكنك معالجة ملف التغطية باستخدام أدوات مناسبة.
مثال Workflow:
- name: Test with coverage
run: >
dotnet test
--configuration Release
--collect:"XPlat Code Coverage"
إذا كان المشروع كبيرًا، يمكن لاحقًا دمج أدوات تقارير Coverage أو خدمات Quality Gate.
المهم ألا تجعل Coverage هدفًا شكليًا. وجود 90% Coverage لا يعني بالضرورة أن الكود جيد. يمكن أن تكون الاختبارات تغطي أسطرًا كثيرة لكنها لا تختبر الحالات المهمة. الهدف الحقيقي هو أن تمنحك الاختبارات ثقة في التغييرات.
بناء Solution كاملة بدل مشروع واحد
إذا كان لديك:
MyShop.sln
يمكنك ببساطة استخدام:
- name: Restore
run: dotnet restore MyShop.sln
- name: Build
run: dotnet build MyShop.sln --configuration Release --no-restore
- name: Test
run: dotnet test MyShop.sln --configuration Release --no-build
وهذا مناسب جدًا للمشاريع متعددة الطبقات.
على سبيل المثال:
MyShop.Domain
MyShop.Application
MyShop.Infrastructure
MyShop.Api
MyShop.UnitTests
MyShop.IntegrationTests
ستستفيد كل المشاريع من Dependency Graph الخاص بـ .NET.
استخدام Matrix Testing
إحدى الميزات المفيدة جدًا في GitHub Actions هي Matrix Strategy. بدل أن تنشئ Jobs منفصلة يدويًا، تستطيع تعريف مجموعة من الإصدارات أو أنظمة التشغيل.
مثلًا:
jobs:
test:
strategy:
matrix:
os:
- ubuntu-latest
- windows-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --configuration Release --no-build
الآن يتم اختبار المشروع على أكثر من Runner.
هذا مفيد خصوصًا إذا كان مشروعك يعتمد على سلوك يختلف بين Windows وLinux، أو إذا كان لديك مكتبات تحتاج إلى اختبار Cross-platform.
لكن لا تستخدم Matrix لمجرد استخدامها. إذا كان مشروع ASP.NET Core مصممًا للعمل على Linux Production فقط، فقد يكون اختبار Linux كافيًا في CI الأساسي.
متى نستخدم Windows Runner؟
إذا كان مشروعك يحتاج إلى Windows-specific components، أو يعتمد على بعض أدوات .NET Framework القديمة، أو يتعامل مع تقنيات متوفرة بشكل أفضل على Windows، فقد تحتاج إلى:
runs-on: windows-latest
أما مشاريع ASP.NET Core الحديثة التي تعمل داخل Linux Containers، فغالبًا يكون:
runs-on: ubuntu-latest
اختيارًا عمليًا ومناسبًا.
Cache لحزم NuGet
عندما يكبر المشروع، قد يصبح Restore أحد الأجزاء التي تستغرق وقتًا ملحوظًا. يمكن استخدام Cache في setup-dotnet.
مثال:
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
cache: true
cache-dependency-path: '**/packages.lock.json'
إذا كنت تستخدم packages.lock.json، يمكن أن يساعد ذلك في جعل Cache أكثر قابلية للتنبؤ.
يمكن أيضًا إدارة Cache بطرق أخرى، لكن المهم هو ألا تجعل تحسين الأداء أهم من وضوح Pipeline. في البداية اجعل Workflow يعمل بشكل صحيح، وبعدها قم بقياس الزمن ثم حسنه.
التعامل مع NuGet Private Feed
إذا كان المشروع يعتمد على Package داخل NuGet خاص أو Azure Artifacts أو GitHub Packages، فلا يجب وضع Token داخل ملف المشروع.
بدلًا من ذلك، استخدم GitHub Secret.
مثلًا:
- name: Configure NuGet
env:
NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }}
run: |
dotnet nuget add source \
"https://nuget.example.com/v3/index.json" \
--name private-feed \
--username github \
--password "$NUGET_TOKEN" \
--store-password-in-clear-text
في التطبيقات الحقيقية، يجب أن تتبع طريقة المصادقة التي يوفرها الـ Registry نفسه، وأن تمنح Token أقل صلاحيات ممكنة.
إدارة Secrets داخل GitHub Actions
من أكثر الأمور حساسية في CI/CD موضوع الأسرار. ستحتاج غالبًا إلى أشياء مثل:
DATABASE_CONNECTION_STRING
DEPLOYMENT_TOKEN
DOCKER_USERNAME
DOCKER_PASSWORD
AZURE_CREDENTIALS
JWT_SECRET
API_KEY
لكن لا تضع هذه القيم داخل YAML:
env:
DATABASE_PASSWORD: "mypassword123"
هذا خطأ واضح.
بدلًا من ذلك:
env:
DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }}
GitHub يوفر Secrets على مستوى Repository أو Organization أو Environment، ويمكن تمريرها إلى Workflow عند الحاجة. كما أن GitHub يوضح أن Secrets يتم تشفيرها ويقوم بإخفاء قيمها في سجلات Workflow، مع ضرورة عدم افتراض أن الإخفاء وحده يغطي كل أشكال تسريب البيانات.
ومن الممارسات المهمة أن تمنح Credentials أقل صلاحيات ممكنة، وألا تستخدم Token أقوى مما يحتاجه Workflow.
Repository Secrets أم Environment Secrets؟
إذا كان لديك:
Development
Staging
Production
فليس من الجيد أن تضع كل الأسرار في Repository بشكل عشوائي.
يمكنك إنشاء Environments في GitHub:
development
staging
production
ثم وضع Secrets خاصة بكل Environment.
مثال:
jobs:
deploy:
environment: production
الآن يستطيع Job الوصول إلى Secrets الخاصة ببيئة Production وفق إعدادات GitHub.
والأجمل أن Environment يمكن أن يحتوي على Protection Rules وموافقات يدوية قبل استمرار عملية النشر. GitHub يوضح أن Environment Secrets لا تصبح متاحة للـ Job إلا عندما يبدأ Job المرتبط بالبيئة، وإذا كانت البيئة تتطلب موافقة، فلا يستطيع Job الوصول إليها قبل موافقة المراجعين المطلوبين.
هذه الميزة ممتازة عندما تريد Continuous Delivery دون جعل Production نشرًا تلقائيًا بالكامل.
إنشاء CI Pipeline احترافي
يمكن أن يصبح Workflow الأساسي أكثر تنظيمًا:
name: .NET CI
on:
push:
branches:
- main
- develop
pull_request:
branches:
- main
- develop
permissions:
contents: read
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
cache: true
cache-dependency-path: '**/packages.lock.json'
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --configuration Release --no-restore
test:
name: Test
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore
- name: Test
run: dotnet test --configuration Release --no-restore
لاحظ أننا استخدمنا:
needs: build
وهذا يعني أن Job test ينتظر Job build.
لكن هنا يوجد سؤال هندسي مهم: هل نريد فعلًا أن يكون Test منفصلًا؟ في بعض المشاريع نعم، وفي بعضها لا. فصل Jobs يمكن أن يعطيك وضوحًا، لكنه قد يزيد وقت التنفيذ لأن كل Job يعمل على Runner منفصل وقد يعيد Checkout وRestore.
لذلك لا يوجد تصميم واحد صحيح لكل المشاريع.
متى نفصل Jobs ومتى نضع كل شيء في Job واحد؟
إذا كان المشروع صغيرًا، قد يكون الأفضل:
Checkout
Setup
Restore
Build
Test
Publish
كلها داخل Job واحد.
أما إذا كان المشروع كبيرًا، فقد تريد:
Job 1: Build
Job 2: Unit Tests
Job 3: Integration Tests
Job 4: Security
Job 5: Package
Job 6: Deploy
ويمكن تشغيل بعض Jobs بالتوازي.
التصميم الجيد ليس التصميم الذي يحتوي على أكبر عدد من Jobs، بل التصميم الذي يعطيك أفضل توازن بين السرعة والوضوح وقابلية الصيانة.
إضافة Static Analysis
يمكنك إضافة خطوات للتحقق من جودة الكود.
مثلًا:
- name: Verify formatting
run: dotnet format --verify-no-changes
هذا يفترض أن المشروع مضبوط بحيث يمكن تشغيل dotnet format.
إذا كان هناك اختلاف في التنسيق، يفشل CI.
وهذا مفيد جدًا في Pull Requests لأنك تمنع اختلافات Style من التراكم داخل المشروع.
يمكن أيضًا استخدام analyzers داخل .csproj:
<PropertyGroup>
<AnalysisLevel>latest</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
لكن يجب استخدام TreatWarningsAsErrors بحذر. إذا فعلته على مشروع قديم مليء بالتحذيرات، فقد تجد أن أول CI يفشل بالكامل بسبب عشرات التحذيرات التي كانت موجودة أصلًا.
في هذه الحالة الأفضل معالجة الوضع تدريجيًا.
اختبار Integration Tests
Unit Tests وحدها ليست كافية دائمًا.
إذا كان لديك API يتعامل مع:
Database
Redis
Message Queue
External Services
فقد تحتاج إلى Integration Tests.
يمكن أن يكون لديك:
tests/
├── MyShop.UnitTests/
└── MyShop.IntegrationTests/
ثم:
- name: Run integration tests
run: >
dotnet test
tests/MyShop.IntegrationTests/MyShop.IntegrationTests.csproj
--configuration Release
إذا كانت الاختبارات تحتاج إلى SQL Server أو PostgreSQL، تستطيع تشغيل Services داخل GitHub Actions.
تشغيل PostgreSQL داخل GitHub Actions
مثال مبسط:
jobs:
integration-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myshop_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore
- name: Test
env:
ConnectionStrings__DefaultConnection: Host=localhost;Port=5432;Database=myshop_test;Username=postgres;Password=postgres
run: dotnet test --configuration Release
بهذا يصبح CI قادرًا على تشغيل Database حقيقية أثناء الاختبارات.
في مشاريع أكثر تعقيدًا، يمكنك استخدام Testcontainers for .NET بدل الاعتماد على تعريف Services داخل Workflow، وهو أسلوب جميل لأنه يجعل بيئة الاختبار جزءًا من كود الاختبار نفسه.
اختبار ASP.NET Core API
يمكن أن تكون لديك اختبارات مثل:
public class ProductsControllerTests
{
[Fact]
public async Task GetProducts_ReturnsSuccess()
{
await using var application = new WebApplicationFactory<Program>();
using var client = application.CreateClient();
var response = await client.GetAsync("/api/products");
response.EnsureSuccessStatusCode();
}
}
ثم:
dotnet test
سيتم تنفيذ الاختبار داخل GitHub Actions تمامًا كما يحدث على جهاز المطور.
وهنا تظهر القيمة الحقيقية للـ CI: المطور لا يحتاج إلى إقناع الفريق بأن الكود "يبدو جيدًا". النظام نفسه يقوم بالتحقق.
بناء Release Package باستخدام dotnet publish
بعد نجاح Build وTests، يمكنك إنشاء نسخة جاهزة للنشر:
- name: Publish
run: >
dotnet publish
src/MyShop.Api/MyShop.Api.csproj
--configuration Release
--output ./publish
--no-restore
يمكن بعدها رفع محتوى publish كـ Artifact.
ما هو Artifact؟
Artifact هو ملف أو مجموعة ملفات ناتجة عن Workflow ويمكن الاحتفاظ بها بعد انتهاء Job.
مثلًا:
publish/
├── MyShop.Api.dll
├── appsettings.json
├── web.config
├── wwwroot/
└── ...
يمكن رفعها:
- name: Upload publish artifact
uses: actions/upload-artifact@v4
with:
name: myshop-api
path: ./publish
وهذا يسمح بفصل عملية Build عن عملية Deploy.
فبدل أن يقوم Deploy بإعادة بناء المشروع، يستطيع أخذ Artifact الذي تم إنتاجه والتحقق منه.
وهذا تصميم مهم جدًا في CI/CD: Build once, deploy the same artifact.
الفكرة أنك لا تريد أن تنتج ملفًا مختلفًا عند كل مرحلة.
فصل CI عن CD
من الأفضل في المشاريع المتوسطة والكبيرة فصل:
ci.yml
عن:
cd.yml
مثلًا:
.github/workflows/
├── ci.yml
└── deploy.yml
ci.yml مسؤول عن:
Restore
Build
Test
Quality
Package
بينما deploy.yml مسؤول عن:
Download Artifact
Deploy Staging
Approval
Deploy Production
لكن مرة أخرى، لا تعتبر هذه قاعدة إلزامية. مشروع صغير قد يكون Workflow واحدًا أفضل.
إنشاء Pipeline للنشر
يمكن أن نكتب:
name: Deploy .NET Application
on:
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: myshop-api
path: ./publish
- name: Deploy
run: |
echo "Deploying application..."
لكن هناك مشكلة: Artifact يجب أن يكون متاحًا من Workflow مناسب. لذلك في المشاريع الحقيقية تحتاج إلى تصميم Trigger وعلاقة Build/Deploy بشكل واضح.
النشر التلقائي إلى Azure App Service
إذا كان تطبيق ASP.NET Core موجودًا على Azure App Service، تستطيع بناء Workflow للنشر.
Microsoft توفر مثالًا رسميًا لنشر تطبيق .NET إلى Azure App Service باستخدام GitHub Actions.
من الناحية المفاهيمية، يصبح المسار:
GitHub
|
v
Build
|
v
Test
|
v
Publish
|
v
Azure App Service
يمكن أن تستخدم Authentication مناسبة لـ Azure بدل وضع بيانات حساسة داخل المستودع.
والأفضل في التصميمات الحديثة هو تفضيل آليات هوية قصيرة العمر مثل OIDC عندما تكون الخدمة السحابية تدعمها، بدل الاعتماد دائمًا على Credentials طويلة العمر. GitHub يوضح إمكانية استخدام OpenID Connect للمصادقة مع Cloud Providers التي تدعمه، مما يساعد على تقليل الحاجة إلى تخزين بيانات اعتماد طويلة العمر كـ Secrets.
النشر باستخدام SSH إلى Linux Server
ليس كل مشروع موجودًا على Azure أو AWS. أحيانًا لديك VPS بسيط يعمل على Ubuntu وNginx وsystemd.
يمكنك نشر ملفات ASP.NET Core عبر SSH.
فكرة Workflow قد تكون:
- name: Publish
run: >
dotnet publish
src/MyShop.Api/MyShop.Api.csproj
-c Release
-o ./publish
- name: Deploy via SSH
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SERVER_HOST: ${{ secrets.SERVER_HOST }}
SERVER_USER: ${{ secrets.SERVER_USER }}
run: |
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh -i ~/.ssh/deploy_key \
-o StrictHostKeyChecking=no \
"$SERVER_USER@$SERVER_HOST" \
"mkdir -p /var/www/myshop"
scp -i ~/.ssh/deploy_key \
-r ./publish/* \
"$SERVER_USER@$SERVER_HOST:/var/www/myshop/"
هذا المثال تعليمي، وفي Production يجب التعامل مع Host Key Verification بطريقة أكثر أمانًا وعدم تعطيلها بلا سبب.
كما يجب أن يكون مستخدم النشر محدود الصلاحيات قدر الإمكان.
لماذا Docker مناسب جدًا مع .NET وGitHub Actions؟
بدل إرسال ملفات publish إلى الخادم، تستطيع إنشاء Docker Image.
مثال Dockerfile:
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["src/MyShop.Api/MyShop.Api.csproj", "src/MyShop.Api/"]
RUN dotnet restore "src/MyShop.Api/MyShop.Api.csproj"
COPY . .
WORKDIR "/src/src/MyShop.Api"
RUN dotnet publish "MyShop.Api.csproj" \
-c Release \
-o /app/publish \
--no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyShop.Api.dll"]
هذا Multi-stage Docker Build.
في المرحلة الأولى نستخدم SDK لأننا نحتاج إلى Compile.
وفي المرحلة الثانية نستخدم ASP.NET Runtime Image فقط.
النتيجة Image أصغر وأكثر مناسبة للتشغيل.
بناء Docker Image داخل GitHub Actions
مثال:
name: Build Docker Image
on:
push:
branches:
- main
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build image
run: |
docker build \
-t myusername/myshop-api:${{ github.sha }} \
.
- name: Push image
run: |
docker push \
myusername/myshop-api:${{ github.sha }}
استخدام:
${{ github.sha }}
مفيد جدًا لأن كل Image ترتبط بوضوح بالـ Commit الذي أنتجها.
بدل:
latest
فقط، لديك:
myshop-api:a7f83c...
وهذا يجعل Rollback أسهل.
لماذا لا يجب الاعتماد على latest فقط؟
تخيل أنك نشرت:
myshop-api:latest
ثم حدث خطأ.
إذا كنت لا تعرف بالضبط ما الذي يحتويه latest، تصبح عملية Rollback مزعجة.
لكن إذا استخدمت:
myshop-api:commit-sha
فيمكنك معرفة الإصدار.
مثلًا:
myshop-api:4e91ab2
myshop-api:7d2f991
myshop-api:a8c4e10
وهكذا تستطيع ربط كل Image بعملية Build وCommit وPull Request.
Tagging باستخدام Git Tag
يمكن أيضًا إنشاء Image عندما تنشئ Release:
on:
push:
tags:
- 'v*.*.*'
ثم:
docker build -t myusername/myshop-api:${GITHUB_REF_NAME} .
docker push myusername/myshop-api:${GITHUB_REF_NAME}
إذا كان Tag:
v2.4.1
تصبح Image:
myshop-api:v2.4.1
وهذا أسهل بكثير في القراءة من SHA فقط.
لكن SHA يبقى مهمًا جدًا للتتبع.
استخدام GitHub Container Registry
يمكنك استخدام GitHub Container Registry بدل Docker Hub.
الصورة قد تكون مثل:
ghcr.io/username/myshop-api
Workflow يحتاج إلى Permissions مناسبة:
permissions:
contents: read
packages: write
ثم تسجيل الدخول باستخدام GITHUB_TOKEN وفق إعدادات المستودع.
مثال:
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
ثم:
- name: Build and push
run: |
IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}"
docker build \
-t "$IMAGE:${GITHUB_SHA}" \
.
docker push \
"$IMAGE:${GITHUB_SHA}"
هذه الطريقة تجعل GitHub Repository وContainer Image قريبين من بعضهما في نفس المنظومة.
CI/CD باستخدام Docker Compose
إذا كان الخادم يستخدم Docker Compose، يمكن أن يكون النشر بسيطًا.
مثال:
services:
api:
image: ghcr.io/example/myshop-api:${IMAGE_TAG}
restart: unless-stopped
ports:
- "8080:8080"
على الخادم:
export IMAGE_TAG=4e91ab2
docker compose pull
docker compose up -d
ومن GitHub Actions يمكنك تنفيذ هذه الأوامر عبر SSH.
مثلًا:
- name: Deploy
env:
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SERVER: ${{ secrets.SERVER }}
IMAGE_TAG: ${{ github.sha }}
run: |
echo "$SSH_KEY" > deploy_key
chmod 600 deploy_key
ssh -i deploy_key "$SERVER" "
export IMAGE_TAG=$IMAGE_TAG &&
cd /opt/myshop &&
docker compose pull &&
docker compose up -d
"
مرة أخرى، هذا مثال تعليمي ويجب تقوية إعداد SSH والمستخدم والصلاحيات في Production.
إضافة Health Check بعد النشر
واحدة من أفضل الأفكار في CD هي ألا تعتبر عملية النشر ناجحة لمجرد أن أمر docker compose up -d أعاد Exit Code صفر.
قد يكون التطبيق بدأ ثم انهار بعد ثوانٍ.
لذلك أضف Health Endpoint:
app.MapGet("/health", () =>
{
return Results.Ok(new
{
status = "healthy",
timestamp = DateTime.UtcNow
});
});
ثم بعد النشر:
curl --fail https://example.com/health
إذا أعاد:
200 OK
يمكن اعتبار الخطوة ناجحة مبدئيًا.
إذا فشل:
curl --fail https://example.com/health
يفشل Workflow.
هذا النوع من التحقق البسيط يمكن أن يوفر عليك كثيرًا من المفاجآت.
Health Checks الحقيقية في ASP.NET Core
يمكنك استخدام Health Checks المدمجة:
builder.Services.AddHealthChecks();
var app = builder.Build();
app.MapHealthChecks("/health");
app.Run();
ثم يمكنك إضافة Database Health Check:
builder.Services
.AddHealthChecks()
.AddSqlServer(
builder.Configuration.GetConnectionString("DefaultConnection")!
);
وهنا يصبح /health قادرًا على إعطاء إشارة أكثر واقعية عن صحة التطبيق.
لكن لا تجعل Health Endpoint يكشف معلومات حساسة.
استراتيجية Staging ثم Production
النشر المباشر إلى Production من كل Push إلى main قد يكون مناسبًا لبعض المشاريع، لكنه ليس دائمًا أفضل اختيار.
تصميم أكثر تحفظًا:
Pull Request
|
v
CI
|
v
Merge
|
v
Build Artifact
|
v
Staging
|
v
Smoke Tests
|
v
Approval
|
v
Production
في GitHub Actions يمكن استخدام Environments لهذا النوع من الفصل. ويمكن أن تتطلب بيئة Production موافقة قبل تنفيذ Job المرتبط بها.
مثال Workflow فيه Staging وProduction
name: Deploy
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build -c Release --no-restore
- name: Test
run: dotnet test -c Release --no-build
- name: Publish
run: >
dotnet publish
src/MyShop.Api/MyShop.Api.csproj
-c Release
-o ./publish
--no-build
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: myshop
path: ./publish
staging:
needs: build
runs-on: ubuntu-latest
environment: staging
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: myshop
path: ./publish
- name: Deploy staging
run: echo "Deploying to staging"
production:
needs: staging
runs-on: ubuntu-latest
environment: production
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: myshop
path: ./publish
- name: Deploy production
run: echo "Deploying to production"
في هذا المثال، production ينتظر staging.
إذا كانت Production Environment تحتوي على Required Reviewer، يمكن أن يتوقف Workflow إلى أن تتم الموافقة.
حماية فرع main
CI/CD وحده لا يكفي.
من الأفضل أيضًا حماية main.
مثلًا يمكنك فرض:
Pull Request required
Status checks required
Review required
No direct push
وهذا يعني أن المطور لا يستطيع دفع كود مباشرة إلى Production Branch.
المسار يصبح:
feature branch
|
v
Pull Request
|
v
CI
|
+--> Build
+--> Test
+--> Quality
|
v
Code Review
|
v
Merge
وهذه واحدة من النقاط التي تجعل CI مفيدًا فعلًا. أنت لا تريد فقط تشغيل Tests، بل تريد استخدام نتائجها لحماية المشروع.
استخدام Conditional Execution
يمكن تشغيل خطوة فقط في حالة معينة.
مثال:
- name: Deploy Production
if: github.ref == 'refs/heads/main'
run: ./deploy.sh
أو:
if: github.event_name == 'push'
أو:
if: success()
لكن انتبه إلى أن الإفراط في استخدام if يجعل Workflow صعب القراءة.
إذا كان لديك عشرات الشروط، قد يكون من الأفضل تقسيم Workflow إلى Jobs أو Workflows أو إعادة التفكير في تصميم العملية.
Workflow يدوي باستخدام workflow_dispatch
ليس كل Deploy يجب أن يكون مرتبطًا بـ Push.
يمكنك السماح بتشغيل Workflow يدويًا:
on:
workflow_dispatch:
ويمكن إضافة Inputs:
on:
workflow_dispatch:
inputs:
environment:
description: "Deployment environment"
required: true
default: "staging"
type: choice
options:
- staging
- production
ثم:
environment: ${{ inputs.environment }}
هذا مفيد عندما تريد أن يختار المسؤول البيئة التي سيذهب إليها الإصدار.
لكن إذا كانت Production حساسة، لا تعتمد على Input وحده للحماية. استخدم Environment Protection Rules أيضًا.
إعادة استخدام Workflows
إذا كان لديك عدة مشاريع .NET داخل Organization، ستجد نفسك تكرر نفس YAML:
checkout
setup-dotnet
restore
build
test
publish
وهنا يمكنك التفكير في Reusable Workflows.
بدل نسخ Workflow لكل مشروع، تنشئ Workflow مركزيًا وتستدعيه المشاريع.
هذا يقلل التكرار.
مثال مبسط:
name: Reusable .NET CI
on:
workflow_call:
inputs:
dotnet-version:
required: true
type: string
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ inputs.dotnet-version }}
- run: dotnet restore
- run: dotnet build -c Release --no-restore
- run: dotnet test -c Release --no-build
ثم مشروع آخر:
name: CI
on:
push:
branches: [main]
jobs:
ci:
uses: my-org/devops/.github/workflows/dotnet-ci.yml@main
with:
dotnet-version: '10.0.x'
في المؤسسات الكبيرة، هذه الطريقة يمكن أن تكون قوية جدًا.
Composite Actions
يمكنك أيضًا إنشاء Composite Action لتجميع مجموعة من الخطوات.
مثلًا بدل أن تكرر:
- checkout
- setup dotnet
- restore
- build
يمكن إنشاء Action خاصة بالمؤسسة.
لكن لا تبدأ بهذا من أول يوم. إذا كان لديك مشروع واحد، YAML واضح غالبًا أفضل. استخدم Abstraction عندما ترى بالفعل تكرارًا حقيقيًا.
إدارة الإصدارات
هناك أكثر من استراتيجية.
استراتيجية تعتمد على Commit SHA
myshop-api:8e7d1c...
ممتازة للتتبع.
استراتيجية تعتمد على Git Tag
myshop-api:v1.4.0
ممتازة للإصدارات الرسمية.
استراتيجية latest
myshop-api:latest
مريحة، لكنها ليست الأفضل للتتبع والـ Rollback إذا استخدمت وحدها.
الأفضل في المشاريع الجادة استخدام Tags واضحة، مع الاحتفاظ بعلاقة الإصدار مع Commit SHA.
Semantic Versioning
يمكن أن تستخدم:
MAJOR.MINOR.PATCH
مثل:
1.0.0
1.1.0
1.1.1
2.0.0
مثال:
on:
push:
tags:
- 'v*.*.*'
عند إنشاء:
git tag v1.2.0
git push origin v1.2.0
يمكن لـ Workflow بناء نسخة:
myshop-api:v1.2.0
Rollback
واحدة من أهم فوائد CI/CD هي إمكانية Rollback.
إذا كان الإصدار الحالي:
v2.1.0
وتبين أن هناك مشكلة، يمكنك إعادة:
v2.0.5
إذا كان لديك Docker:
docker pull ghcr.io/example/myshop-api:v2.0.5
ثم:
docker compose up -d
إذا كنت تنشر ملفات:
releases/
├── 2.0.5/
├── 2.0.6/
├── 2.1.0/
└── current -> 2.1.0
يمكن تغيير Symlink إلى الإصدار السابق.
النقطة المهمة: لا تبنِ Rollback على أساس إعادة بناء الكود القديم من جديد. احتفظ بالArtifact أو Image الذي تم اختباره.
لماذا Build Once, Deploy Many مهم؟
افترض أنك بنيت التطبيق صباحًا:
Build -> Artifact A
ثم نشرته إلى Staging:
Artifact A -> Staging
ثم Production:
Artifact A -> Production
هنا أنت متأكد أن ما اختبرته في Staging هو نفسه ما نشرته في Production.
أما إذا فعلت:
Build -> Staging
Build -> Production
فأنت أنشأت Artifactين.
حتى لو كان الكود نفسه، يمكن أن تختلف Dependency أو Environment أو Build Context.
لذلك التصميم الأقوى غالبًا هو:
Source
|
v
Build
|
v
Immutable Artifact
|
+--> Staging
|
+--> Production
التعامل مع appsettings في CI/CD
في ASP.NET Core لديك:
appsettings.json
appsettings.Development.json
appsettings.Production.json
لكن لا تضع Secrets داخل هذه الملفات.
مثلًا لا تكتب:
{
"ConnectionStrings": {
"DefaultConnection": "Server=prod;User=admin;Password=secret"
}
}
داخل Git.
بدلًا من ذلك استخدم Environment Variables أو Secret Manager أو منصة إدارة أسرار.
ASP.NET Core يستطيع قراءة:
ConnectionStrings__DefaultConnection
كـ:
ConnectionStrings:DefaultConnection
وهذا يجعل CI/CD أسهل.
مثال:
env:
ConnectionStrings__DefaultConnection: ${{ secrets.DATABASE_CONNECTION_STRING }}
الفرق بين Configuration وSecret
ليس كل Environment Variable سرًا.
مثلًا:
ASPNETCORE_ENVIRONMENT=Production
ليست Secret بالضرورة.
بينما:
DATABASE_PASSWORD
JWT_SECRET
API_TOKEN
هي أسرار.
يمكنك استخدام GitHub Variables للقيم غير الحساسة:
env:
APP_NAME: ${{ vars.APP_NAME }}
واستخدام Secrets للقيم الحساسة:
env:
API_KEY: ${{ secrets.API_KEY }}
هذا الفصل يجعل إعداداتك أكثر وضوحًا.
تقليل Permissions في GitHub Actions
من الأخطاء التي أراها كثيرًا إعطاء Workflow صلاحيات أكبر من حاجته.
مثلًا:
permissions: write-all
قد يكون مريحًا، لكنه ليس أفضل ممارسة.
إذا كان Workflow يحتاج فقط إلى قراءة الكود:
permissions:
contents: read
وإذا احتاج إلى نشر Package:
permissions:
contents: read
packages: write
الفكرة هي Least Privilege.
GitHub نفسه يوصي بتقليل الصلاحيات قدر الإمكان عند التعامل مع Credentials وTokens.
GITHUB_TOKEN
GitHub يوفر Token تلقائيًا في Workflow:
GITHUB_TOKEN
يمكن الوصول إليه عبر:
${{ secrets.GITHUB_TOKEN }}
لكن صلاحياته تعتمد على إعدادات Permissions.
لذلك لا تفترض أن وجود Token يعني أنه يستطيع تنفيذ أي شيء.
مثال:
permissions:
contents: read
packages: write
ثم:
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
الحذر من Pull Requests القادمة من Forks
إذا كان مستودعك عامًا، يمكن أن تأتي Pull Requests من Forks.
لا تفترض أن Secrets الخاصة بالمستودع ستكون متاحة لهذه الأحداث. GitHub يفرض قيودًا على وصول Secrets في بعض سياقات Forks وDependabot.
وهذه نقطة أمنية مهمة جدًا.
لا تجعل Workflow الخاص بـ Pull Request من كود غير موثوق ينفذ أوامر خطيرة باستخدام Credentials Production.
خصوصًا إذا كان لديك:
run: ./script.sh
وكان script.sh جزءًا من Pull Request.
لأن الكود الذي يتم تشغيله داخل Runner يمكن أن يحاول الوصول إلى Environment Variables.
تجنب وضع أسرار داخل Command Line
حتى لو كانت Secret، حاول ألا تفعل:
some-command --password "${{ secrets.PASSWORD }}"
بشكل عشوائي.
الأفضل استخدام Environment:
env:
PASSWORD: ${{ secrets.PASSWORD }}
ثم:
some-command --password "$PASSWORD"
GitHub يحذر أيضًا من تمرير Secrets عبر سطر الأوامر عندما يمكن استخدام Environment أو STDIN أو آلية أكثر أمانًا.
التعامل مع Logs
Logs مهمة جدًا في CI/CD، لكنها يمكن أن تصبح مصدر تسريب إذا لم تنتبه.
تجنب:
echo "$DATABASE_CONNECTION_STRING"
وتجنب:
printenv
إذا كانت Environment تحتوي على Secrets.
حتى مع وجود Masking، لا تجعل استراتيجية الأمان تعتمد على إخفاء القيمة في Log فقط.
القاعدة البسيطة:
لا تطبع Secret أصلًا.
تحسين سرعة Pipeline
عندما يبدأ CI، قد يستغرق:
Restore: 20s
Build: 40s
Tests: 90s
Docker: 120s
ثم يصبح لديك 5 دقائق لكل Pull Request.
إذا كان الفريق يفتح عشرات PRs يوميًا، تصبح المشكلة حقيقية.
ابدأ بقياس الزمن.
لا تحاول تحسين شيء لم تقسه.
Cache NuGet
استخدم Cache عندما يكون مناسبًا.
تجنب Restore المتكرر
إذا كنت تستطيع إعادة استخدام نتيجة مناسبة، افعل ذلك.
Parallel Jobs
يمكن تشغيل:
Unit Tests
Lint
Static Analysis
بالتوازي إذا كانت مستقلة.
Docker Layer Caching
إذا كان Docker Build يستغرق وقتًا كبيرًا، استخدم BuildKit وCaching.
مثال Docker Build مع Buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/example/myshop-api:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
هذا يجعل Docker Cache جزءًا من عملية البناء.
التعامل مع Database Migrations
هذه من أكثر النقاط حساسية في CD.
إذا كان المشروع يستخدم Entity Framework Core:
dotnet ef database update
لا يعني أنك يجب أن تنفذه مباشرة في Production عند كل Deployment دون تفكير.
هناك مخاطر مثل:
Migration طويلة
Breaking schema change
Locking
Rollback صعب
Data transformation
في بعض المشاريع يمكن توليد SQL Script:
dotnet ef migrations script
ثم تتم مراجعته وتنفيذه بطريقة منظمة.
مثال:
dotnet ef migrations script \
--idempotent \
-o migration.sql
يمكن بعدها حفظ Script كـ Artifact.
Idempotent Migrations
الـ Idempotent Script يمكن أن يكون مفيدًا عندما لا تعرف بالضبط ما هي Migration التي تم تطبيقها على Database.
لكن يجب التعامل معه بحذر واختبار Migration على Staging أولًا.
القاعدة الذهبية هنا:
Database Deployment ليس مجرد خطوة صغيرة بجانب Application Deployment.
قاعدة البيانات لها دورة حياة ومخاطرها الخاصة.
استراتيجية Expand and Contract
إذا كنت تحتاج إلى تغيير Database حساس، لا تفعل:
Rename Column
في خطوة واحدة إذا كان التطبيق القديم ما زال يستخدم الاسم القديم.
بدلًا من ذلك:
Version 1:
Add new column
Version 2:
Application writes both columns
Version 3:
Migrate data
Version 4:
Application reads new column
Version 5:
Remove old column
هذا يجعل Deployment أكثر أمانًا.
Notifications بعد فشل CI/CD
يمكن إرسال Notification إلى Slack أو Teams أو البريد عند الفشل.
لكن لا تبدأ بهذا قبل أن تجعل Pipeline نفسها مستقرة.
عندما تصبح العملية مهمة، تستطيع ربط GitHub Actions مع أدوات التواصل.
مثلًا من حيث المبدأ:
- name: Notify
if: failure()
run: ./notify.sh
لكن يجب عدم وضع Webhook Secret مباشرة في الملف.
استخدم:
env:
WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }}
Status Badges
يمكن وضع Badge في README:

وهذا يعطي الزائر فكرة سريعة عن حالة المشروع.
إذا كانت Build خضراء، فهذا لا يعني أن المشروع خالٍ من الأخطاء، لكنه يعني أن شروط CI الحالية ناجحة.
Semantic Workflow Naming
لا تسمِّ كل شيء:
workflow.yml
test.yml
deploy.yml
فقط.
إذا كان لديك عدة Workflows:
ci.yml
docker.yml
deploy-staging.yml
deploy-production.yml
security.yml
release.yml
يمكن أن تكون الأسماء أوضح.
والـ Workflow نفسه:
name: .NET CI - Build and Test
أفضل من:
name: test
لأن الاسم يظهر في GitHub UI.
استخدام Path Filters
إذا كان لديك Monorepo:
apps/
api/
admin/
worker/
ليس منطقيًا أن تعيد بناء كل شيء عند تعديل README.
يمكنك استخدام:
on:
push:
paths:
- 'apps/api/**'
- 'shared/**'
- '.github/workflows/api.yml'
لكن انتبه إلى Dependencies بين المشاريع.
إذا كان API يعتمد على:
shared/
يجب أن يدخل shared/** في Paths.
Monorepo و.NET
في Monorepo يمكن أن يكون لديك:
src/
├── Catalog/
├── Orders/
├── Identity/
└── Notifications/
وعدة Solutions.
يمكن إنشاء Workflow لكل Domain أو Workflow مركزي يستخدم Matrix.
لكن التصميم يعتمد على درجة الاستقلال بين المشاريع.
إذا كانت كل التطبيقات تشترك في 90% من Libraries، قد يكون CI مركزيًا أفضل.
GitHub Actions وDocker وKubernetes
إذا كان مشروعك يعمل على Kubernetes، قد يصبح المسار:
Git Push
|
v
GitHub Actions
|
+--> dotnet restore
+--> dotnet build
+--> dotnet test
|
+--> docker build
+--> docker push
|
v
Container Registry
|
v
Kubernetes
ثم:
kubectl set image deployment/myshop-api \
api=ghcr.io/example/myshop-api:${GITHUB_SHA}
لكن في Production الحديثة قد تفضل GitOps بدل أن تقوم GitHub Actions مباشرة بتعديل Kubernetes.
مثل:
GitHub Actions
|
v
Container Registry
Git Repository
|
v
GitOps Tool
|
v
Kubernetes
هذا موضوع أكبر، لكنه يوضح أن GitHub Actions ليست نهاية الطريق، بل جزء من منظومة DevOps.
GitHub Actions و.NET Worker Services
ليس كل مشروع .NET هو ASP.NET Core.
يمكن أن يكون لديك Worker:
public class Worker : BackgroundService
{
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
Console.WriteLine("Worker running...");
await Task.Delay(
TimeSpan.FromSeconds(10),
stoppingToken);
}
}
}
CI له نفس الأساس:
dotnet restore
dotnet build
dotnet test
dotnet publish
لكن Deployment قد يكون:
systemd
Docker
Kubernetes
Windows Service
Azure Container Apps
GitHub Actions مع Console Applications
حتى تطبيق Console بسيط يستفيد من CI.
name: .NET Console CI
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
- run: dotnet restore
- run: dotnet build -c Release --no-restore
- run: dotnet test -c Release --no-build
هذا يؤكد أن CI/CD ليست مرتبطة بـ ASP.NET Core فقط.
بناء Packages باستخدام NuGet
إذا كان لديك Class Library:
MyCompany.Logging
يمكن نشر Package إلى Registry.
مثال:
dotnet pack \
src/MyCompany.Logging/MyCompany.Logging.csproj \
-c Release \
-o ./packages
ثم:
dotnet nuget push \
"./packages/*.nupkg" \
--source "https://api.nuget.org/v3/index.json" \
--api-key "$NUGET_API_KEY"
لكن لا تضع:
NUGET_API_KEY
داخل YAML.
استخدم:
env:
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
إصدار Package تلقائيًا
يمكنك أخذ Version من Git Tag.
مثلًا:
env:
VERSION: ${{ github.ref_name }}
ثم:
dotnet pack \
-c Release \
-p:PackageVersion=${VERSION}
إذا كان Tag:
1.3.0
ينتج:
MyCompany.Logging.1.3.0.nupkg
التعامل مع GitHub Releases
يمكن إنشاء Release عندما يتم Push إلى Tag.
المفهوم:
git tag v1.5.0
|
v
GitHub Actions
|
+--> Build
+--> Test
+--> Package
|
v
GitHub Release
وهذا يجعل إصدار البرنامج قابلاً للتتبع.
كيف نكتب Pipeline لا يخاف منها الفريق؟
هذه نقطة إنسانية أكثر من كونها تقنية.
أحيانًا نكتب Workflow ضخمة:
500 lines YAML
وتحتوي:
20 Jobs
60 Steps
15 Conditions
10 Secrets
ثم يأتي خطأ صغير ويحتاج شخصًا متخصصًا في GitHub Actions حتى يفهمه.
الهدف ليس كتابة أكثر Workflow تعقيدًا.
الهدف هو كتابة Workflow يمكن لأي مطور في الفريق أن يقرأه ويفهمه.
اجعل الخطوات واضحة:
- name: Restore dependencies
run: dotnet restore
- name: Build application
run: dotnet build -c Release --no-restore
- name: Run tests
run: dotnet test -c Release --no-build
هذه البساطة لها قيمة كبيرة.
اجعل CI يفشل مبكرًا
إذا كان هناك شيء رخيص وسريع يمكن أن يفشل، شغله مبكرًا.
مثل:
Format
Restore
Build
Unit Tests
Integration Tests
Docker Build
Deploy
لا تنتظر حتى تقوم ببناء Docker Image لمدة 4 دقائق ثم تكتشف أن Unit Test فشل.
يمكنك ترتيب:
Restore
Build
Unit Tests
Integration Tests
Docker
Deploy
حسب احتياجات المشروع.
Unit Tests قبل Integration Tests
غالبًا Unit Tests أسرع.
لذلك:
Build
|
+--> Unit Tests
|
+--> Integration Tests
يمكن أن يكون مناسبًا.
وإذا كانت Unit Tests تفشل، لا داعي لتشغيل Integration Tests.
Job Dependencies
يمكنك استخدام:
integration-tests:
needs: unit-tests
ثم:
docker:
needs:
- unit-tests
- integration-tests
ثم:
deploy:
needs: docker
وهكذا تحصل على Graph واضح:
Build
|
v
Unit Tests
|
v
Integration Tests
|
v
Docker
|
v
Deploy
التعامل مع فشل Deployment
ماذا يحدث إذا نجح Build وفشل Deploy؟
لا تريد أن تعيد Build بالضرورة.
إذا كنت تستخدم Artifact أو Image ثابتة:
Build -> Image SHA
يمكنك إعادة محاولة Deployment لنفس Image.
هذا أفضل من:
Build again
Deploy again
لأن المشكلة قد تكون Network أو Server وليس Code.
Retry بحذر
يمكن إعادة محاولة بعض الخطوات:
- name: Deploy
run: ./deploy.sh
لكن لا تستخدم Retry عشوائيًا لكل شيء.
مثلاً:
Build failed
لا معنى لإعادة Build عشر مرات.
أما:
Temporary network error
قد تكون إعادة المحاولة منطقية.
Timeouts
ضع Timeouts للـ Jobs الحساسة:
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 15
إذا تعلق Deployment بسبب مشكلة Network، لا تريد أن يبقى Runner يعمل إلى أجل غير محدد.
Concurrency
إذا قام مطور بعمل:
Commit A
Commit B
Commit C
بسرعة، قد يبدأ ثلاثة Deployments.
يمكن أن يؤدي ذلك إلى:
Deploy A
Deploy B
Deploy C
وفي النهاية يصل A بعد C بسبب اختلاف زمن التنفيذ، فتجد Production على نسخة قديمة.
هنا يمكن استخدام Concurrency.
مثال:
concurrency:
group: production
cancel-in-progress: false
يمكن تعديل الاستراتيجية حسب طبيعة المشروع.
في CI الخاص بـ Pull Requests قد يكون:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
مفيدًا، بحيث إذا وصل Commit جديد، يمكن إلغاء CI القديم غير المهم.
حماية Production من Deployment متزامن
لـ Production، عادةً تريد:
Deployment A
|
v
Deployment B ينتظر
بدل أن ينفذا معًا.
هذا مهم جدًا في الأنظمة التي لا تتحمل نشرين متداخلين.
استخدام OIDC بدل Long-Lived Credentials
إذا كنت تنشر إلى Cloud Provider يدعم OIDC، فهذا خيار يستحق الدراسة.
الفكرة:
GitHub Actions
|
| short-lived identity
v
Cloud Provider
بدل:
GitHub Secret
|
| long-lived credential
v
Cloud Provider
GitHub يوضح أن OIDC يمكن استخدامه مع Cloud Providers المدعومة لتقليل الاعتماد على Credentials طويلة العمر.
هذا ليس مجرد تحسين نظري. تقليل عمر Credential يقلل مساحة الضرر إذا حدث تسريب.
Security Scanning
يمكن إضافة فحوصات أمنية.
مثل:
Dependency vulnerabilities
Secret scanning
Code scanning
Container scanning
لكن لا تجعل Security Job تعطي نتائج كثيرة لا يستطيع الفريق التعامل معها.
ابدأ بالأهم.
إذا كان المشروع يستخدم Dependencies كثيرة، راقب Vulnerabilities.
إذا كان لديك Docker، افحص Image.
إذا كان لديك Secrets، راقب تسريباتها.
Dependency Updates
المكتبات تتغير باستمرار.
مشروع .NET قد يحتوي على:
ASP.NET Core
Entity Framework Core
Serilog
Swashbuckle
Newtonsoft.Json
FluentValidation
xUnit
يجب أن تكون هناك استراتيجية لتحديثها.
يمكن استخدام Dependabot أو أدوات مشابهة لإنشاء Pull Requests للتحديثات.
وهنا يعود CI إلى دوره:
Dependency Update
|
v
Pull Request
|
v
Build
|
v
Tests
|
v
Review
إذا فشل اختبار بسبب تحديث مكتبة، تعرف ذلك قبل Production.
التعامل مع Breaking Changes
ليس كل تحديث آمنًا.
مثلًا:
Library 1.x
->
Library 2.x
قد يحتوي على Breaking Changes.
لذلك لا تجعل CI مجرد:
dotnet restore
dotnet build
بل اجعل Tests حقيقية بما يكفي لاكتشاف التغييرات.
Quality Gates
يمكن أن تضع شروطًا مثل:
Build must pass
Tests must pass
Coverage >= X
No critical vulnerability
Code analysis pass
لكن لا تجعل الأرقام أهم من الجودة.
إذا كان Coverage:
89%
ثم أصبح:
88%
ليس بالضرورة أن الكود أصبح سيئًا.
الأهم هو فهم ما الذي تم اختباره.
مثال كامل لـ CI احترافي
يمكن أن يكون لدينا:
name: .NET CI
on:
pull_request:
branches:
- main
push:
branches:
- main
permissions:
contents: read
concurrency:
group: dotnet-ci-${{ github.ref }}
cancel-in-progress: true
jobs:
build-test:
name: Build and Test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
cache: true
cache-dependency-path: '**/packages.lock.json'
- name: Restore
run: dotnet restore
- name: Check formatting
run: dotnet format --verify-no-changes --no-restore
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Unit tests
run: >
dotnet test
--configuration Release
--no-build
--filter "Category=Unit"
- name: Integration tests
run: >
dotnet test
--configuration Release
--no-build
--filter "Category=Integration"
هذا ليس قالبًا يجب نسخه كما هو في كل مشروع، لكنه يوضح شكل Pipeline ناضجة نسبيًا.
مثال CD باستخدام Docker
بعد CI:
name: Docker CD
on:
push:
branches:
- main
permissions:
contents: read
packages: write
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Buildx
uses: docker/setup-buildx-action@v3
- name: Build and Push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/example/myshop-api:${{ github.sha }}
ghcr.io/example/myshop-api:latest
cache-from: type=gha
cache-to: type=gha,mode=max
في Production يمكنك تجنب استخدام latest كمرجع وحيد، واستخدام SHA أو Release Tag كأساس للنشر.
GitHub Actions مع ASP.NET Core على VPS
لنأخذ سيناريو شائعًا جدًا.
لديك:
Ubuntu Server
Nginx
Docker
ASP.NET Core
PostgreSQL
والكود على GitHub.
Pipeline:
Developer
|
v
Pull Request
|
v
CI
|
+--> Restore
+--> Build
+--> Test
|
v
Merge main
|
v
Docker Build
|
v
GHCR
|
v
SSH
|
v
docker compose pull
|
v
docker compose up -d
|
v
Health Check
هذا التصميم يمكن أن يكون كافيًا جدًا لتطبيق SaaS صغير أو متوسط.
مثال deploy.sh
على الخادم:
#!/usr/bin/env bash
set -euo pipefail
cd /opt/myshop
echo "Pulling new image..."
docker compose pull api
echo "Starting services..."
docker compose up -d api
echo "Checking application..."
sleep 10
curl --fail http://localhost:8080/health
echo "Deployment completed successfully."
ثم GitHub Actions:
- name: Deploy
env:
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SERVER: ${{ secrets.SERVER }}
run: |
echo "$SSH_KEY" > deploy_key
chmod 600 deploy_key
ssh -i deploy_key "$SERVER" \
"/opt/myshop/deploy.sh"
الأفضل أن يكون Deployment Script موجودًا على الخادم أو في مستودع DevOps مستقل إذا كان ذلك يناسب بنية الفريق.
لا تجعل GitHub Actions يعرف كل أسرار البنية التحتية
من الخطأ أن تجعل Workflow يحتوي:
50 commands
20 server paths
10 database commands
15 nginx commands
إذا كان كل شيء موجودًا داخل YAML، يصبح CI/CD نفسه صعب الصيانة.
يمكن نقل بعض المنطق إلى Scripts:
scripts/
├── build.sh
├── test.sh
├── deploy.sh
└── health-check.sh
ثم:
- run: ./scripts/build.sh
الميزة أن المطور يستطيع تشغيل:
./scripts/build.sh
محليًا.
وهذا يجعل Debugging أسهل.
Makefile لمشاريع .NET
يمكن أيضًا استخدام Makefile:
restore:
dotnet restore
build:
dotnet build -c Release --no-restore
test:
dotnet test -c Release --no-build
publish:
dotnet publish src/MyShop.Api/MyShop.Api.csproj \
-c Release \
-o ./publish
ci: restore build test
ثم Workflow:
- name: CI
run: make ci
هذا أسلوب جميل عندما يكون الفريق معتادًا على Make.
PowerShell بدل Bash
إذا كان المشروع يعمل بشكل أساسي على Windows، يمكنك استخدام PowerShell:
- name: Build
shell: pwsh
run: |
dotnet restore
dotnet build --configuration Release --no-restore
لا تفترض أن Bash هو الحل الوحيد.
متغيرات Environment
يمكن تعريف Environment Variables على مستوى Workflow:
env:
DOTNET_VERSION: '10.0.x'
CONFIGURATION: 'Release'
ثم:
- uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- run: dotnet build --configuration ${{ env.CONFIGURATION }}
لكن لا تجعل كل قيمة متغيرًا فقط من أجل جعل YAML يبدو احترافيًا.
إذا كانت القيمة تستخدم مرة واحدة:
dotnet-version: '10.0.x'
قد تكون أوضح.
Environment Variables مقابل GitHub Variables
إذا كانت لديك:
APP_NAME
DEPLOY_REGION
IMAGE_NAME
يمكن وضعها كـ Variables.
مثال:
env:
APP_NAME: ${{ vars.APP_NAME }}
أما:
DATABASE_PASSWORD
فتبقى Secret.
هذا الفصل البسيط يقلل أخطاء الإعداد.
كيف تتعامل مع Configuration لكل بيئة؟
يمكن أن يكون:
Development:
API_URL=http://localhost
LOG_LEVEL=Debug
Staging:
API_URL=https://staging.example.com
LOG_LEVEL=Information
Production:
API_URL=https://example.com
LOG_LEVEL=Warning
ولا تحتاج إلى بناء ثلاث نسخ مختلفة من التطبيق.
يمكن بناء Image واحدة:
myshop-api:abc123
ثم تشغيلها بإعدادات مختلفة في كل Environment.
وهذا يعود بنا إلى فكرة:
Build once, configure per environment.
مشكلة شائعة: استخدام appsettings.Production.json للأسرار
قد يكون من المغري كتابة:
{
"Jwt": {
"Secret": "..."
}
}
لكن هذا يخلق خطرًا كبيرًا إذا دخل الملف إلى Git.
الأفضل:
appsettings.json
+
Environment Variables
+
Secret Manager
وهذا يجعل Git Repository خاليًا من أسرار Production.
التعامل مع Certificates
إذا كان مشروعك يحتاج Certificate، لا تضع .pfx داخل Git إلا إذا كانت مشفرة ومصممة لهذا الغرض.
الأفضل استخدام Secret Management أو Cloud Secret Store.
إذا اضطررت إلى تمرير ملف Certificate إلى Workflow، يمكنك تخزين Base64 في Secret ثم إعادة تكوين الملف مؤقتًا:
- name: Create certificate
env:
CERTIFICATE_BASE64: ${{ secrets.CERTIFICATE_BASE64 }}
run: |
echo "$CERTIFICATE_BASE64" | base64 --decode > certificate.pfx
ثم احذف الملف بعد الاستخدام:
rm -f certificate.pfx
لكن تذكر أن Secrets الكبيرة لها حدود، وGitHub يضع قيودًا على حجم Secrets؛ توضح وثائق GitHub أن الحد العام للـ Secret هو 48 KB، مع آليات أخرى للبيانات الأكبر.
مشكلة YAML والمسافات
YAML حساس للمسافات.
هذا:
jobs:
build:
runs-on: ubuntu-latest
صحيح.
أما:
jobs:
build:
runs-on: ubuntu-latest
فهو مختلف.
عندما تحصل على:
Invalid workflow file
ابدأ بفحص:
Indentation
Colon
Quotes
Lists
Expression syntax
Expressions في GitHub Actions
سترى كثيرًا:
${{ github.sha }}
أو:
${{ secrets.API_KEY }}
أو:
${{ matrix.os }}
هذه Expressions يتم تقييمها بواسطة GitHub Actions.
مثلًا:
name: Build ${{ github.ref_name }}
أو:
- run: echo "Commit: ${{ github.sha }}"
لكن لا تطبع Secrets.
Contexts
هناك Contexts مثل:
github
env
vars
secrets
matrix
steps
needs
inputs
مثال:
${{ github.repository }}
يعطي Repository.
و:
${{ github.actor }}
يعطي المستخدم الذي تسبب في الحدث.
و:
${{ needs.build.result }}
يمكن أن يساعدك في معرفة نتيجة Job سابق.
فهم Contexts يجعل كتابة Workflow أسهل بكثير.
استخدام Outputs بين Jobs
يمكن أن تنتج Job قيمة:
outputs:
image_tag: ${{ steps.meta.outputs.tag }}
ثم Job أخرى تستخدم:
${{ needs.build.outputs.image_tag }}
هذا مفيد عندما تنتج Build رقم إصدار أو Image Tag.
مثال لتوليد Image Tag
- name: Set image tag
id: meta
run: |
echo "tag=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
ثم:
outputs:
tag: ${{ steps.meta.outputs.tag }}
وفي Job أخرى:
- name: Deploy
run: |
echo "Deploying ${{ needs.build.outputs.tag }}"
Debugging GitHub Actions
عندما يفشل Workflow، لا تبدأ بتعديل YAML عشوائيًا.
اسأل:
أي Job فشل؟
أي Step فشل؟
ما هو Exit Code؟
ما هو الأمر الذي تم تنفيذه؟
هل المشكلة في GitHub أم .NET أم المشروع؟
مثلًا إذا:
dotnet restore
فشل:
NuGet
Network
Private Feed
SDK
Project
إذا:
dotnet build
فشل:
Compilation
Analyzer
Configuration
Dependency
إذا:
dotnet test
فشل:
Test
Database
Environment
Timing
إذا:
docker push
فشل:
Authentication
Registry
Permissions
Tag
هذه العقلية تختصر وقتًا كبيرًا.
لا تستخدم continue-on-error لإخفاء المشاكل
قد ترى:
continue-on-error: true
وتعتقد أنه حل لفشل Step.
أحيانًا هو مناسب.
لكن إذا وضعت:
- run: dotnet test
continue-on-error: true
فقد أصبح CI ناجحًا حتى لو فشلت الاختبارات.
وهذا يهزم الغرض الأساسي من CI.
استخدمه فقط عندما تكون فعلًا تريد أن تكون الخطوة غير مانعة.
التعامل مع Flaky Tests
أسوأ شيء في CI هو Test يفشل مرة وينجح مرة.
مثل:
Run 1: Pass
Run 2: Fail
Run 3: Pass
هذا يسمى Flaky Test.
لا تعالج المشكلة دائمًا بإعادة الاختبار.
ابحث عن السبب:
Race Condition
Time Zone
Database State
Random Data
Network
Shared Resource
قد تكون إعادة المحاولة مفيدة مؤقتًا، لكنها ليست علاجًا جذريًا.
Time Zone في Tests
إذا كان التطبيق يتعامل مع التاريخ، تأكد من استخدام UTC عند الحاجة:
DateTime.UtcNow
وفي الاختبارات لا تعتمد على:
DateTime.Now
إذا كان سلوكك حساسًا للمنطقة الزمنية.
CI قد يعمل في بيئة مختلفة عن جهازك.
Culture Differences
أحيانًا يفشل Test على Linux لأن جهازك يستخدم Culture معينة.
مثل:
en-US
ar-MA
fr-FR
لا تعتمد على تنسيق نصوص محلي داخل Logic حساس.
استخدم Culture صراحة عندما يكون ذلك مطلوبًا.
هذه من الحالات التي تجعل CI مفيدًا: فهو يكشف افتراضات محلية لم تكن تراها على جهازك.
Case Sensitivity
هذه مشكلة مشهورة عند الانتقال من Windows إلى Linux.
قد يكون:
Products.cs
وفي الكود:
using products;
أو لديك مسار File مختلف في حالة الأحرف.
Windows قد يسمح بسلوك لا يعمل على Linux.
إذا كان Production على Linux، فمن المفيد جدًا أن يكون CI أيضًا قريبًا من Production.
لماذا Ubuntu Runner مفيد لتطبيقات Linux؟
إذا كان Production:
Ubuntu + Docker
فوجود CI على:
ubuntu-latest
يجعل البيئة أقرب.
ليس تطابقًا كاملًا، لكنه يقلل بعض المفاجآت.
Self-hosted Runners
GitHub Actions لا تقتصر على GitHub-hosted runners. يمكنك استخدام Self-hosted Runner.
هذا مفيد إذا كنت تحتاج:
Private Network
Special Hardware
Internal Database
Custom Software
GPU
لكن Self-hosted Runner مسؤولية أمنية أكبر.
لا تضع Runner على جهاز حساس دون فهم نموذج الأمان، خصوصًا إذا كان يمكن تشغيل كود من Pull Requests غير موثوقة.
في كثير من المشاريع، GitHub-hosted Runner هو البداية الأبسط والأكثر أمانًا.
GitHub-hosted Runner أم Self-hosted؟
استخدم GitHub-hosted إذا:
لا تحتاج شبكة داخلية
لا تحتاج Hardware خاص
لا تحتاج أدوات مخصصة جدًا
فكر في Self-hosted إذا:
CI يحتاج الوصول إلى Private Network
لديك Hardware خاص
لديك متطلبات تشغيل غير متاحة على Hosted Runner
ولا تستخدم Self-hosted فقط لأنك تريد توفير بضعة دقائق أو لأنك تعتقد أنه "أكثر احترافية".
حماية Self-hosted Runner
إذا كان لديك Self-hosted Runner، اعتبره جزءًا حساسًا من البنية التحتية.
لا تسمح لعمل غير موثوق بالوصول إلى:
SSH Keys
Cloud Credentials
Internal APIs
Production Secrets
دون ضوابط قوية.
مثال Pipeline لبيئة Production ناضجة
يمكن تخيل:
Pull Request
|
+--> Build
+--> Unit Tests
+--> Integration Tests
+--> Static Analysis
|
v
Merge
|
v
Build Artifact
|
v
Docker Image
|
v
Container Registry
|
v
Staging
|
+--> Health Check
+--> Smoke Tests
|
v
Approval
|
v
Production
|
+--> Health Check
|
v
Monitoring
هذه ليست قاعدة يجب تنفيذها بالكامل من اليوم الأول، لكنها تصور جيد لكيفية نمو Pipeline.
Smoke Tests
بعد نشر Staging، يمكن تشغيل:
curl --fail https://staging.example.com/health
curl --fail https://staging.example.com/api/products
أو استخدام Tests حقيقية:
dotnet test tests/MyShop.SmokeTests
بهذا لا يكون النجاح مبنيًا على أن Container بدأ فقط.
Canary Deployment
في الأنظمة الكبيرة، يمكنك نشر الإصدار الجديد إلى نسبة صغيرة من المستخدمين.
مثلًا:
95% -> v1
5% -> v2
إذا كان v2 جيدًا:
50% -> v2
ثم:
100% -> v2
GitHub Actions يمكن أن يكون جزءًا من هذا النظام، لكن تنفيذ Canary يعتمد على البنية التحتية مثل Kubernetes أو Load Balancer أو Cloud Platform.
Blue-Green Deployment
تصميم آخر:
Blue = current
Green = new
تنشر النسخة الجديدة إلى Green.
تختبرها.
ثم تحول Traffic:
Blue -> Green
إذا حدثت مشكلة:
Green -> Blue
هذا يجعل Rollback سريعًا.
لكن تكلفة البنية التحتية أكبر لأنك قد تحتاج بيئتين.
GitHub Actions ليس Monitoring
من المهم عدم الخلط.
GitHub Actions يعرف:
Deployment started
Deployment completed
Command failed
لكنه ليس بديلًا عن:
Application Monitoring
Logs
Metrics
Tracing
Alerts
APM
بعد النشر تحتاج أن تعرف:
هل CPU مرتفع؟
هل Error Rate ارتفع؟
هل Database بطيئة؟
هل API Response Time ارتفع؟
يمكن ربط Deployment مع أدوات Observability، لكن هذا جزء منفصل من المنظومة.
Deployment Metadata
من الجميل أن يعرف النظام:
Version: v2.4.1
Commit: 8e2a...
Build: 1034
Environment: production
Deployed by: GitHub Actions
يمكن أن تعرض هذه المعلومات في Endpoint مثل:
/version
مثال:
app.MapGet("/version", () =>
{
return Results.Ok(new
{
version = "2.4.1",
commit = "8e2a9c1"
});
});
وفي CI يمكن تمريرها أثناء Build.
تمرير Version إلى .NET Build
يمكن استخدام:
dotnet publish \
-c Release \
-p:Version=2.4.1 \
-p:InformationalVersion=2.4.1+8e2a9c1
وهذا يجعل تتبع النسخ أسهل.
مثال متقدم للـ Version
- name: Calculate version
id: version
run: |
VERSION="0.0.0-${GITHUB_RUN_NUMBER}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
ثم:
- name: Publish
run: >
dotnet publish
src/MyShop.Api/MyShop.Api.csproj
-c Release
-p:Version=${{ steps.version.outputs.version }}
-p:InformationalVersion=${{ steps.version.outputs.version }}+${{ github.sha }}
توثيق CI/CD داخل المشروع
لا تفترض أن YAML وحده كافٍ.
ضع في README:
## CI/CD
Every Pull Request runs:
- Restore
- Build
- Unit Tests
- Integration Tests
Merges to main:
- Build Docker image
- Push image
- Deploy staging
Production deployments require approval.
هذا مفيد جدًا للمطور الجديد.
توثيق Secrets
لا تكتب قيمة Secret.
لكن يمكنك توثيق اسمها:
Required GitHub Secrets:
DATABASE_CONNECTION_STRING
SSH_PRIVATE_KEY
SERVER_HOST
DOCKER_USERNAME
DOCKER_PASSWORD
ويجب أن تعرف وظيفة كل Secret.
مثال:
SERVER_HOST
The hostname of the production deployment server.
Naming Conventions للأسرار
استخدم أسماء واضحة:
PROD_SSH_PRIVATE_KEY
PROD_SERVER_HOST
STAGING_SERVER_HOST
DOCKER_USERNAME
DOCKER_PASSWORD
أو إذا كنت تستخدم Environments:
SSH_PRIVATE_KEY
SERVER_HOST
داخل كل Environment.
الأهم هو الاتساق.
GitHub يفرض قواعد لأسماء Secrets، منها السماح بالحروف والأرقام والشرطة السفلية وعدم بدء الاسم بـ GITHUB_.
لا تستخدم Personal Account Credentials للنشر إذا كان يمكن تجنب ذلك
إذا كان Workflow يعتمد على حساب شخصي، قد يتوقف عند مغادرة ذلك الشخص للفريق.
الأفضل استخدام:
Service Account
GitHub App
OIDC
Deployment Identity
حسب النظام.
GitHub يشير إلى أن GitHub Apps يمكن أن تكون خيارًا أفضل من Personal Access Tokens في بعض السيناريوهات لأنها ليست مرتبطة بحساب مستخدم واحد ويمكنها استخدام صلاحيات دقيقة وTokens قصيرة العمر.
Pull Request Checks
السيناريو المثالي:
عندما يفتح المطور PR:
GitHub
|
v
CI
|
+--> Build
+--> Tests
+--> Format
+--> Security
ثم يظهر:
All checks have passed
بعدها يستطيع Reviewer مراجعة الكود بثقة أكبر.
هذا يجعل Code Review أكثر جودة؛ بدل أن يضيع وقت المراجع في اكتشاف أن المشروع لا يبني أصلًا، يقوم CI بهذا العمل أولًا.
كيف تتعامل مع مشروع قديم؟
إذا كان لديك .NET Project قديم، لا تحاول تحويله إلى CI/CD مثالي في يوم واحد.
ابدأ:
Step 1:
Build
Step 2:
Build + Unit Tests
Step 3:
Formatting
Step 4:
Integration Tests
Step 5:
Publish
Step 6:
Staging
Step 7:
Production
إذا كان المشروع لا يحتوي على Tests، لا تنتظر حتى كتابة 500 اختبار.
ابدأ بأهم المسارات.
مشروع بدون Tests
يمكنك على الأقل:
- run: dotnet build -c Release
لكن هذا CI ضعيف.
أضف اختبارات تدريجيًا:
Authentication
Orders
Payments
Users
ابدأ بالأجزاء التي قد يؤدي فشلها إلى خسارة أو مشاكل للمستخدم.
لا تجعل CI بطيئًا جدًا
إذا أصبح:
CI = 25 minutes
قد يبدأ المطورون في كرهه.
وإذا بدأوا في تجاهله، فقدت CI قيمته.
حافظ على:
Fast Feedback
Reliable Tests
Clear Logs
Predictable Builds
هذه أهم من عدد الخطوات.
قاعدة عملية مهمة: CI يجب أن يعطي Feedback سريعًا
إذا كان Unit Tests يمكن تشغيلها خلال 30 ثانية، اجعلها مبكرة.
أما Integration Tests التي تحتاج Database فقد تستغرق دقيقتين.
اجعل المسار:
Restore
Build
Unit Tests
Integration Tests
حتى تحصل على Feedback سريع.
Artifact Retention
لا تحتفظ بكل Artifact إلى الأبد.
إذا كان لديك:
100 builds/day
500 MB/build
ستتراكم البيانات بسرعة.
حدد Retention مناسبًا بحسب احتياجاتك.
لا تحتاج غالبًا إلى الاحتفاظ بكل Build تجريبي لمدة سنة.
لكن Releases Production قد تحتاج إلى احتفاظ أطول.
Logs والخصوصية
تذكر أن Logs قد تحتوي على:
URLs
Usernames
Paths
Error Messages
Configuration
لا تسجل بيانات المستخدم الحساسة.
خصوصًا إذا كانت Tests تتعامل مع بيانات حقيقية.
الأفضل استخدام Test Database ببيانات اصطناعية.
لا تستخدم Production Database في CI
هذه قاعدة مهمة جدًا.
لا تجعل Integration Tests تنفذ:
INSERT
UPDATE
DELETE
على Production.
أنشئ:
test database
staging database
حتى لو كانت صغيرة.
Seed Data للاختبارات
يمكن إنشاء بيانات:
Admin
Customer
Product
Order
داخل Test Fixture.
مثال:
public static class TestData
{
public static Product CreateProduct()
{
return new Product
{
Name = "Test Product",
Price = 100
};
}
}
وهكذا تكون الاختبارات مستقلة.
التعامل مع External APIs
إذا كان التطبيق يتصل بخدمة:
Payment API
SMS API
Email API
Shipping API
لا تجعل CI يعتمد على الخدمة الحقيقية قدر الإمكان.
استخدم:
Mock
Stub
Fake
Test API
Sandbox
لأن External API يمكن أن يكون:
Down
Slow
Rate Limited
Changed
وإذا فشل CI بسبب خدمة خارجية، تحصل على Feedback غير موثوق.
Integration Testing مع Testcontainers
Testcontainers for .NET يسمح لك بتشغيل Containers أثناء الاختبارات.
مثلًا:
var container = new PostgreSqlBuilder()
.WithImage("postgres:16")
.Build();
await container.StartAsync();
ثم تستخدم Connection String الخاصة بالـ Container.
هذه الطريقة تجعل Integration Tests أقرب إلى البيئة الحقيقية، ويمكن أن تقلل الاعتماد على Configuration خارجية.
Docker Compose في Integration Tests
يمكن أيضًا استخدام:
docker compose up -d postgres redis
ثم:
dotnet test
وبعدها:
docker compose down
لكن يجب تنظيف الموارد حتى لا يؤثر Test على التالي.
Cleanup
استخدم:
if: always()
لخطوة Cleanup عندما تحتاج إليها.
مثال:
- name: Cleanup
if: always()
run: docker compose down -v
وهذا مفيد جدًا مع Services الاختبارية.
التعامل مع Migration في Integration Tests
يمكن عند بدء Test Application تنفيذ:
Database.Migrate()
على Test Database فقط.
لكن لا تستخدم هذا الأسلوب بشكل عشوائي في Production.
البيئة تختلف.
كيف تعرف أن CI/CD ناجح فعلًا؟
ليس عندما يصبح Badge أخضر فقط.
النجاح الحقيقي يعني:
1. Build reliable
2. Tests meaningful
3. Deploy repeatable
4. Secrets protected
5. Rollback possible
6. Logs understandable
7. Production observable
8. Team trusts pipeline
هذه النقطة الأخيرة مهمة جدًا.
إذا كان الفريق لا يثق في CI، سيبدأ في تجاهل Failures.
إذا كان كل Failure حقيقيًا تقريبًا، تصبح النتيجة مختلفة:
Red = stop and fix
Green = safe to proceed
أكثر الأخطاء شيوعًا في GitHub Actions مع .NET
الخطأ الأول: استخدام SDK مختلف
المشروع يعمل محليًا:
.NET SDK 10.x
لكن Runner يستخدم إصدارًا آخر.
الحل:
uses: actions/setup-dotnet@v5
مع تحديد الإصدار.
الخطأ الثاني: تجاهل global.json
إذا كان المشروع يعتمد على SDK محدد، لا تجعل CI يتصرف بشكل غير متوقع.
الخطأ الثالث: عدم تشغيل Tests
Build ناجح لا يعني أن التطبيق صحيح.
الخطأ الرابع: Secrets داخل Git
خطأ خطير.
الخطأ الخامس: Deployment مباشر إلى Production
قد يكون مناسبًا، لكن يجب أن يكون قرارًا واعيًا.
الخطأ السادس: استخدام latest فقط
يجعل Rollback والتتبع أصعب.
الخطأ السابع: عدم وجود Health Check
قد تعتقد أن Deployment نجح بينما التطبيق لا يستجيب.
الخطأ الثامن: Workflow ضخمة جدًا
يصعب صيانتها.
الخطأ التاسع: تجاهل Logs
الـ Log غالبًا يخبرك بالضبط أين فشل النظام.
الخطأ العاشر: عدم وجود Rollback
إذا لم تعرف كيف ترجع للإصدار السابق، فأنت لم تكمل تصميم CD.
Checklist قبل تفعيل Production CD
المشروع يبني من CLI.
الاختبارات تعمل محليًا.
CI يعمل على Pull Request.
mainمحمي.Secrets ليست داخل Git.
Permissions محدودة.
Production Environment محمية.
Deployment يستخدم Artifact أو Image ثابتة.
Health Check موجود.
Rollback معروف ومجرب.
Logs واضحة.
Monitoring موجود.
Database migrations لها استراتيجية واضحة.
CI لا يعتمد على Production Database.
External APIs لا تجعل CI هشًا.
Docker Image لها Version واضح.
الفريق يعرف كيف يعيد تشغيل Deployment.
الفريق يعرف كيف يعمل Rollback.
مثال نهائي متكامل
لنضع تصورًا لمشروع:
MyShop
يحتوي:
src/MyShop.Api
src/MyShop.Application
src/MyShop.Domain
src/MyShop.Infrastructure
tests/MyShop.UnitTests
tests/MyShop.IntegrationTests
والهدف:
Pull Request
|
v
Build + Test
|
v
Merge
|
v
Docker Image
|
v
GHCR
|
v
Staging
|
v
Smoke Test
|
v
Production Approval
|
v
Production
يمكن أن يكون CI:
name: .NET CI
on:
pull_request:
branches:
- main
push:
branches:
- main
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
cache: true
cache-dependency-path: '**/packages.lock.json'
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build -c Release --no-restore
- name: Unit Tests
run: >
dotnet test
tests/MyShop.UnitTests/MyShop.UnitTests.csproj
-c Release
--no-build
- name: Integration Tests
run: >
dotnet test
tests/MyShop.IntegrationTests/MyShop.IntegrationTests.csproj
-c Release
--no-build
ثم Docker:
name: Docker
on:
push:
branches:
- main
permissions:
contents: read
packages: write
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Buildx
uses: docker/setup-buildx-action@v3
- name: Build and Push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/example/myshop-api:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
ثم Deployment:
name: Production Deployment
on:
workflow_dispatch:
inputs:
image_tag:
description: "Docker image tag"
required: true
type: string
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
steps:
- name: Deploy
env:
IMAGE_TAG: ${{ inputs.image_tag }}
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SERVER: ${{ secrets.SERVER_HOST }}
run: |
echo "$SSH_KEY" > deploy_key
chmod 600 deploy_key
ssh -i deploy_key "$SERVER" \
"IMAGE_TAG=$IMAGE_TAG /opt/myshop/deploy.sh"
هنا يصبح Production Deployment منفصلًا عن Build، ويمكن للمسؤول تحديد Image Tag التي سيتم نشرها.
لماذا هذا التصميم أفضل من "git pull على الخادم"؟
طريقة:
git pull
dotnet publish
systemctl restart
قد تعمل، لكنها تربط Production مباشرة بمصدر الكود.
أما:
Git
|
v
CI
|
v
Tested Artifact
|
v
Registry
|
v
Production
فتفصل Source عن Artifact وعن Runtime.
وهذا يجعل التتبع وRollback أسهل.
GitHub Actions ليس مجرد أداة نشر
قد ينظر بعض المطورين إلى GitHub Actions ويقولون:
"هي مجرد طريقة لتشغيل deploy.sh."
لكن هذه النظرة تقلل من قيمتها.
GitHub Actions يمكن أن تصبح منصة Automation كاملة للمشروع:
Pull Requests
Code Quality
Testing
Packaging
Releases
Docker
Deployment
Security
Documentation
Notifications
Maintenance
وهذا ما يجعلها جزءًا مهمًا من DevOps.
الجانب الإنساني في CI/CD
هناك جانب لا نتحدث عنه كثيرًا.
عندما يكون النشر يدويًا، غالبًا يصبح المطور متوترًا قبل Production.
يبدأ السؤال:
هل نسيت تحديث ملف الإعدادات؟
ثم:
هل شغلت Migration؟
ثم:
هل رفعت آخر DLL؟
ثم:
هل أعدت تشغيل الخدمة؟
ثم:
لماذا Production لا تعمل؟
هذا النوع من التوتر لا علاقة له بقدرة المطور على البرمجة. إنه نتيجة طبيعية لعملية تعتمد على الذاكرة.
CI/CD لا يلغي المشاكل، لكنه ينقل الكثير من المسؤولية من الإنسان إلى النظام.
بدل أن تقول:
"أتمنى أنني لم أنس خطوة."
تستطيع أن تقول:
"هناك Workflow يحتوي الخطوات التي نحتاجها."
وهذا فرق نفسي وتقني كبير.
لا تؤتمت الفوضى
لكن هناك تحذير مهم.
إذا كانت عملية النشر اليدوية غير واضحة، فلا تحولها مباشرة إلى YAML.
أولًا:
Document the process
ثم:
Simplify it
ثم:
Automate it
إذا كان Deployment يحتاج إلى 30 خطوة يدوية، اسأل:
لماذا؟
ربما يمكن اختصارها إلى:
docker compose pull
docker compose up -d
أو:
Deploy artifact
الأتمتة الجيدة تبدأ بعملية جيدة.
ابدأ صغيرًا
إذا كان لديك مشروع .NET جديد اليوم، لا تحتاج إلى بناء منصة DevOps ضخمة.
ابدأ بـ:
1. GitHub Repository
2. ci.yml
3. Restore
4. Build
5. Test
ثم:
6. Publish
7. Artifact
ثم:
8. Docker
9. Registry
ثم:
10. Staging
11. Production
12. Approval
13. Rollback
بهذا تتطور المنظومة مع المشروع.
الخلاصة
دمج GitHub Actions مع مشاريع .NET ليس مجرد إضافة ملف YAML إلى مستودع GitHub، بل هو انتقال في طريقة التفكير من النشر اليدوي إلى عملية قابلة للتكرار والقياس والمراجعة. تستطيع أن تبدأ من Workflow صغير يقوم بـ restore وbuild وtest، ثم توسع العملية تدريجيًا لتشمل dotnet publish، Artifacts، Docker، Container Registries، Staging، Production، Health Checks، Secrets، Environments، Security Checks وRollback.
في مشاريع .NET الحديثة، يمكن أن يصبح GitHub Actions نقطة التقاء مهمة بين الكود والبنية التحتية. المشروع يبدأ من Pull Request، يمر عبر Build وTests، ثم ينتج Artifact أو Docker Image يمكن تتبعها باستخدام Commit SHA أو Version Tag، وبعد ذلك يتم نشر نفس الناتج إلى Staging ثم Production. Microsoft توثق استخدام GitHub Actions لبناء واختبار ونشر تطبيقات .NET، بينما توفر GitHub إمكانيات مخصصة للأسرار والبيئات والحماية والموافقات، مما يجعل بناء Pipeline متدرجة أكثر سهولة.
والأهم من كل هذه الأدوات هو تصميم العملية نفسها. لا تجعل هدفك أن تكتب أطول Workflow أو أن تستخدم أكبر عدد من Actions. الهدف هو أن تصبح عملية تطوير ونشر التطبيق أكثر موثوقية. يجب أن يكون الـ CI سريعًا بما يكفي ليعطي Feedback مبكرًا، والاختبارات قوية بما يكفي لمنع الأخطاء المهمة، والأسرار محمية، والصلاحيات محدودة، والـ Deployment قابلًا للتكرار، والـ Rollback ممكنًا، وProduction مراقبة بعد النشر.
إذا أردت بناء نظام عملي، فابدأ من الأساس: اجعل مشروع .NET يبني من CLI، أضف اختبارات حقيقية، أنشئ ci.yml، اربطه بالـ Pull Requests، ثم أضف Publishing وArtifacts. بعد ذلك، إذا كان المشروع مناسبًا، انتقل إلى Docker وContainer Registry، ثم أنشئ Staging Environment، وأخيرًا أضف Production مع Approval وHealth Checks وRollback.
بهذه الطريقة لا تصبح GitHub Actions مجرد ملف داخل .github/workflows، بل تصبح جزءًا من هندسة المشروع نفسها.
وفي النهاية، أفضل CI/CD ليس النظام الذي يحتوي على عشرات الخطوات، بل النظام الذي يجعلك تنام وأنت تعرف أن عملية النشر غدًا ستتم تقريبًا بنفس الطريقة التي تمت بها اليوم. عندما يصبح Build متكررًا، والاختبار تلقائيًا، والـ Artifact واضحًا، والنشر قابلًا للتتبع، والـ Rollback جاهزًا، تكون قد انتقلت فعلًا من "نشر التطبيق" إلى هندسة عملية تسليم برمجية موثوقة.