تخطي إلى المحتوى
Architecture Decision Record

نشر المونوريبو

pnpm workspace بتلات تطبيقات، كل واحد بيتنشر لوحده حسب المسار المتغيّر — قرار الاستضافة الذاتية، والتقسيم حسب المسار، وعطل إنتاج حقيقي غيّر طريقة كتابة متغيّرات البيئة.
الفهرس

نظرة عامة

المبدأ الأساسي

المشروع عبارة عن pnpm workspace فيه ثلاثة تطبيقات — client، server، وdb — بيتشاركوا الأدوات ولوك-فايل واحد، لكن كل واحد بيتنشر لوحده: push بيلمس تطبيق واحد بس ميعملش rebuild للتانيين.

النشر نفسه عبر self-hosted GitHub Actions runner + PM2 على VPS بيشغّل CloudPanel — بدون Docker، وبدون منصة إدارة container. القرار مبني على واقع إنتاج فعلي شغّال دلوقتي، مش تصميم نظري — بما فيه حادثة إنتاج حقيقية غيّرت طريقة كتابة متغيّرات البيئة، موثّقة بالتفصيل تحت.

مخطط النشر

خمس خطوات بالتتابع، من الـ push لحد ما التطبيق يبقى شغّال على النطاق العام
Push على main
أي تغيير على الفرع الرئيسي يشغّل الـ pipeline تلقائياً
GitHub Actions Workflow
self-hosted runner بيستقبل الحدث ويبدأ التنفيذ
مزامنة الكود ومتغيّرات البيئة
الكود بيتزامن مع آخر نسخة، وملفات .env لكل تطبيق بتتكتب من متغيّرات الريبو
scripts/deploy.sh
بيحدد التطبيقات المتأثرة ويبنيها/يعيد تشغيلها بالتتابع
CloudPanel (nginx)
بيوجّه كل نطاق للتطبيق المسؤول عنه ويدير شهادة SSL

جدول التطبيقات

التطبيقات الثلاثة
التطبيقالمسار في الـ workspaceالدور
clientapps/clientالموقع — Nuxt 4، البورتفوليو والمدونة وصفحات الـ ADR
serverapps/serverREST API — Express، مصدر البيانات لكل التطبيقات التانية
dbapps/dbلوحة الإدارة — Nuxt 4، لإدارة محتوى الموقع (مشاريع، مقالات، ADRs)

آلية الديبلوي الانتقائي

مين اللي بيقرر إيه اللي يتبني، وإزاي

الأداة الفعلية اللي بتقرر مين يتبني هي دالة want() جوه scripts/deploy.sh. المنطق بسيط: السكريبت بياخد قايمة المسارات اللي اتغيّرت (عبر git diff بين آخر commit قبل الـ push وبعده)، ولو أي مسار منها بادئته بتطابق مسار تطبيق معيّن، التطبيق ده بيتبني. لو الملف اللي اتغيّر هو pnpm-lock.yaml، أو الديبلوي أول مرة أو شغّال يدوي، التلات تطبيقات بيتبنوا كلهم.

want() — منطق القرار (deploy.sh)bash
BUILD_ALL=0
if [ "$CHANGED" = "ALL" ] || echo "$CHANGED" | grep -q '^pnpm-lock.yaml$'; then
  BUILD_ALL=1
fi

want() { [ "$BUILD_ALL" = 1 ] || echo "$CHANGED" | grep -q "^$1/"; }

if want apps/client; then
  # ...build client...
fi

الآليتان الحقيقيتان اللي بتحدد النتيجة: فحص git diff للمسارات المتغيّرة، ومطابقة بادئة المسار لكل تطبيق. جدول "ديبلوي موحّد مقابل حسب المسار" تحت في القرارات التصميمية بيشرح ليه الاختيار ده تحديداً، مش بس إزاي بيشتغل.

ما تغيّر أثناء التشغيل — حادثة CRLF

عطل إنتاج حقيقي، وأثره على طريقة كتابة متغيّرات البيئة في النشر
1المشكلة

/projects رجّعت 404 في الإنتاج، بينما باقي صفحات الموقع (زي الصفحة الرئيسية) شغّالة عادي — نفس الديبلوي، صفحة واحدة بس واقعة.

2السبب الجذري

أول تشخيص افترض إن الكاش القديم لبناء Nuxt هو السبب — اتصلح بمسح الكاش قبل كل بناء، بس المشكلة فضلت موجودة، وده نفى الفرضية دي فعلياً. السبب الحقيقي كان \r زايد في نهاية قيمة BASE_URL (جايّة من صياغة CRLF في متغيّرات النشر)، وده كان بيكسر بناء الرابط في الطلبات اللي معاها query string بس — و/projects هي الصفحة الوحيدة في الموقع اللي بتنادي useAPI بـ query، فكانت الوحيدة المتأثرة.

3الحل

الحل كان في مستويين — تنضيف قيمة متغيّرات البيئة من الـ \r قبل ما تتكتب في ملف .env وقت النشر، مع إضافة طبقة حماية دفاعية في useAPI نفسها بتشيل أي مسافة أو محرف زايد من قيمة الـ baseURL قبل ما تُستخدم — عشان أي تلوث مشابه في المستقبل ميكررش نفس المشكلة.

4المنع

الدرس الأهم مكنش في الإصلاح نفسه، لكن في إن الاعتماد على كتابة صحيحة لمتغيّرات البيئة يدوياً مش كافي — التعقيم لازم يبقى آلي جوه سكريبت النشر نفسه، مش افتراض إن القيمة هتوصل نضيفة دايماً.

القرارات التصميمية

لماذا اخترنا هذا الشكل من النشر وليس البدائل الأخرى

السبب الأساسي: server (Express) محتاج عملية Node دائمة (long-running process)، مش serverless functions — وده وحده كافي يمنع نشره بشكله القياسي على منصة زي Vercel. التكلفة عامل مساعد بس، مش سبب مستقل.

(أ) VPS مستضاف ذاتياً مقابل منصة مُدارة (Vercel/Netlify)
المعيارVPS مستضاف ذاتياًمنصة مُدارة (Vercel/Netlify)
طبيعة server (Express)عملية Node دائمة — مطلوبة للـ REST APIserverless functions — مش مناسبة لعملية دائمة
التقسيمالتلات تطبيقات على نفس البنيةكان محتاج تقسيم على أكتر من منصة
التكلفة (عامل مساعد)VPS واحد ثابت التكلفة لتلات تطبيقاتتكلفة شهرية مضاعفة لتلات تطبيقات منفصلة
(ب) Monorepo واحد مقابل ريبوهات منفصلة
المعيارMonorepo واحدريبوهات منفصلة
الأدوات والإعداداتمشتركة بين التلات تطبيقاتكل ريبو بيكرر إعداداته لوحده
الـ lockfileواحد (pnpm-lock.yaml) لكل الـ dependencieslockfile منفصل لكل ريبو
النشرمستقل حسب المسار المتغيّرمستقل بطبيعته (pipeline خاص لكل ريبو)
(ج) ديبلوي موحّد مقابل ديبلوي حسب المسار
المعيارديبلوي موحّد (الكل مع بعض)ديبلوي حسب المسار (الحالي)
وقت البناءبناء التلات تطبيقات في كل push، حتى لو اتغيّر واحد بسبناء التطبيق المتأثر بس
استهلاك المواردموارد السيرفر ما بتستحملش 3 عمليات build في نفس الوقت — فحتى لو اتبنوا واحد ورا التاني، وقت النشر بيطول من غير داعيبناء أقصر — تطبيق واحد بس بيتبني في الحالة الشائعة
الآليةمفيش حاجة تحدد إيه اللي اتغيّردالة want() بتقارن مسارات git diff ببادئة كل تطبيق