الأدلة التقنية

أدوات البناء Build Tools وماذا يحدث عندما تبني مشروعك

ما الذي يحدث بين ضغطة البناء والملف الناتج، ولماذا ينجح البناء على جهازك ويفشل على سيرفر CI، ومتى يعطيك نفس النتيجة مرتين.

أدوات البناء Build Tools وماذا يحدث عندما تبني مشروعك

كل مطور يضغط زر البناء Build في محرره عشرات المرات في اليوم، وفي خط CI يكتب سطراً مثل mvn package أو dotnet build وينتظر النتيجة الخضراء، وغالباً لا يسأل نفسه ماذا حدث بين الضغطة والملف الناتج حتى يأتي يوم يفشل فيه البناء على سيرفر CI وينجح على جهازه، أو يأخذ البناء عشر دقائق بعد أن كان دقيقة، أو يطلب منك المدقق أن تثبت أن الملف المنشور في الإنتاج بني فعلاً من الكود الموجود في المستودع، وعندها تكتشف أن «البناء» عملية لها خطوات ومدخلات ومخرجات، وأن فهمها يوفر عليك ساعات من التخمين.

وفي مقال أنماط البرمجة وأنظمة الأنواع تكلمنا عن الفرق بين اللغات المترجمة Compiled واللغات المفسرة Interpreted، وفي هذا المقال سوف ننتقل خطوة إلى الأمام ونرى ما الذي تفعله أدوات البناء Build Tools فوق المترجم Compiler. وسوف نناقش في هذا المقال ما يلي:

  • ماذا تعني كلمة البناء: الترجمة Compile والربط Link والاختبار Test والتحزيم Package والناتج Artifact.
  • Make كأصل الفكرة، ولماذا ما زالت قاعدته تعمل في كل أداة حديثة.
  • Maven ودورة حياته وملف pom.xml، ثم Gradle، ثم dotnet وMSBuild، ثم npm scripts والمجمعات Bundlers باختصار، ثم البناء المدمج في Go وCargo.
  • حل الاعتماديات Dependency Resolution وملفات القفل Lock Files باختصار.
  • البناء التدريجي Incremental Build وذاكرة البناء Build Cache.
  • البناء القابل للتكرار Reproducible Build: ما تعريفه، وما الذي يكسره.
  • كيف يعمل البناء داخل خط CI.
  • تجربة عملية نبني فيها نفس البرنامج الصغير بـ Maven وGradle وdotnet داخل Containers، مع المخرجات والأوقات الحقيقية، ثم Make وGo باختصار.

ماذا يحدث عندما تضغط زر البناء؟

البناء Build هو تحويل الكود المصدري Source Code الذي يكتبه الإنسان إلى ملف أو مجموعة ملفات يمكن تشغيلها أو نشرها، ويسمى هذا الملف الناتج Artifact، مثل ملف jar في Java، أو ملف dll في .NET، أو ملف تنفيذي Executable في Go وC، أو مجلد dist فيه ملفات JavaScript مضغوطة لموقع ويب. والصورة التالية تبين الخطوات التي تمر بها أغلب عمليات البناء مهما كانت اللغة:

ست بطاقات مرقمة من اليمين إلى اليسار: Source فيها ملف Vat.java وpom.xml، ثم Resolve وفيها سحابة Maven Central لتنزيل الاعتماديات، ثم Compile وفيها ترس javac وcsc وgcc، ثم Test وفيها علامة صح JUnit وxUnit، ثم Package وفيها صندوق vat-1.0.0.jar، ثم Publish وفيها سيرفر registry. قوس أزرق تحت الخطوات من 2 إلى 5 مكتوب عليه أنها ما يفعله أمر واحد مثل mvn package، وقوس برتقالي تحت النشر بأنه خطوة منفصلة في CI
خطوات البناء في أغلب اللغات، والأسماء تختلف من أداة إلى أخرى

وفيما يلي معنى كل خطوة باختصار:

  • حل الاعتماديات Dependency Resolution: قراءة قائمة المكتبات التي يحتاجها المشروع من ملف الوصف، ثم تنزيلها مع المكتبات التي تحتاجها هي بدورها Transitive Dependencies إلى ذاكرة محلية على الجهاز.
  • الترجمة Compile: تحويل الكود إلى bytecode كما في Java و.NET، أو إلى كود آلة Machine Code كما في C وGo وRust، وفي هذه الخطوة تظهر أخطاء الأنواع والأسماء.
  • الربط Link: في اللغات التي تنتج كود آلة، يجمع الرابط Linker ملفات الكائنات Object Files والمكتبات في ملف تنفيذي واحد، أما في Java و.NET فالربط يحدث غالباً وقت التشغيل.
  • الاختبار Test: تشغيل اختبارات الوحدة Unit Tests، وإذا فشل اختبار واحد يتوقف البناء ولا يخرج أي ملف، وهذا مقصود.
  • التحزيم Package: وضع الناتج في شكل قابل للنقل، مثل jar أو حزمة NuGet أو Docker Image.
  • النشر Publish: رفع الناتج إلى مستودع Repository أو Registry حتى تستخدمه مشاريع أخرى أو تنشره إلى السيرفر، وهذه الخطوة تكون عادةً في خط CI وليس على جهاز المطور.

وقد يتساءل البعض: لماذا أحتاج أداة بناء أصلاً، ما دام المترجم javac أو gcc موجوداً؟ والإجابة أن مشروعاً حقيقياً فيه مئات الملفات وعشرات المكتبات، وكل واحدة منها بنسخة محددة، وترتيب يجب أن تبنى به الأجزاء، واختبارات يجب أن تعمل قبل التحزيم، فإذا كتبت كل هذا كأوامر يدوية في ملف نصي سوف تحصل على سكربت لا يعرف ما الذي تغير، فيعيد ترجمة كل شيء في كل مرة، ولا يعرف من أين ينزل المكتبات، ويعمل على جهاز كاتبه فقط. وأداة البناء تحل هذه المشكلات الثلاث: تعرف الاعتماديات بين الملفات، وتعرف مصدر المكتبات ونسخها، وتعرف ماذا تعيد بناءه وماذا تترك.

Make: من أين بدأت الفكرة؟

في ورقة Make: A Program for Maintaining Computer Programs التي نشرها Stuart Feldman من Bell Laboratories ضمن توثيق الإصدار السابع من Unix بتاريخ 15 أغسطس 1978، يصف المشكلة التي جاءت منها الأداة بوضوح: في أي مشروع برمجي من السهل أن تفقد متابعة الملفات التي تحتاج إلى إعادة ترجمة بعد تعديل جزء من الكود، فإذا نسيت ترجمة ملف تغير سوف تحصل على برنامج لا يعمل وخطأ يصعب تتبعه، وإذا أعدت ترجمة كل شيء احتياطاً فهذا هدر كبير. وقد حصل Feldman على جائزة ACM Software System Award عام 2003 عن Make، ونص الجائزة يقول إنه لا يكاد يوجد نظام برمجي كبير في العالم لم يمر عبر نسخة من Make أو أداة خرجت منها.

والفكرة بسيطة: تكتب في ملف Makefile قواعد Rules، وكل قاعدة فيها هدف Target والملفات التي يعتمد عليها Prerequisites والأوامر Recipe التي تنتجه، ثم يقرر Make بنفسه ما يعيد بناءه، والقاعدة كما يكتبها دليل GNU Make أن الهدف قديم Out of Date إذا لم يكن موجوداً أو كان أقدم من أي ملف يعتمد عليه، بمقارنة وقت آخر تعديل Modification Time. والصورة التالية تبين ذلك على المثال الذي سوف نشغله في التجربة العملية:

مخطط اعتماديات: الملف التنفيذي vat في الأعلى يعتمد على main.o وvat.o، وmain.o يعتمد على main.c وvat.h، وvat.o يعتمد على vat.c وvat.h. الملف vat.c عليه وقت 10:07 touched باللون الأحمر، والمسار الأحمر من vat.c إلى vat.o إلى vat يعاد بناؤه، وmain.o مكتوب عليه up to date. وفي اليمين القاعدة بالعربية وقاعدتان من Makefile
Make يبني مخطط الاعتماديات ويعيد بناء ما تغير تحته فقط

لاحظ أن Make لا يعرف شيئاً عن لغة C، فهو يعرف الملفات وأوقاتها والأوامر فقط، ولذلك ما زال يستخدم حتى اليوم لأغراض لا علاقة لها بالترجمة، مثل ملف Makefile في المجلد الرئيسي للمشروع فيه أوامر مختصرة مثل make test وmake deploy. ولكن هناك مشكلتان رئيسيتان في Make عندما يكبر المشروع: الأولى أنه لا يعرف شيئاً عن المكتبات الخارجية ومن أين تنزل ونسخها، والثانية أن المقارنة بالوقت تخدعه أحياناً، فإذا نسخت الملفات أو استرجعتها من Git بأوقات جديدة يظن أن كل شيء تغير، وإذا تغير خيار في الأمر نفسه مثل CFLAGS لا يعرف أن الناتج يجب أن يتغير. ومن هنا جاءت أدوات تعرف اللغة والمكتبات، وأشهرها في عالم Java أداة Apache Ant ثم Maven ثم Gradle.

Maven: اصطلاح بدل الإعداد

Ant كان يشبه Make بصيغة XML: أنت تكتب كل خطوة بنفسك، وكل مشروع له شكل مختلف، أما Maven فقد عكس الفكرة: بدل أن تكتب الخطوات تصف المشروع، وتلتزم بشكل ثابت للمجلدات (src/main/java للكود وsrc/test/java للاختبارات)، وMaven يعرف الخطوات وحده. ووصف المشروع يكون في ملف pom.xml واختصاره Project Object Model، وفيه إحداثيات المشروع Coordinates (groupId وartifactId وversion) والاعتماديات Dependencies والإضافات Plugins.

دورة حياة البناء Build Lifecycle

بحسب توثيق Maven لدورة حياة البناء هناك ثلاث دورات مدمجة: default للبناء والنشر، وclean للتنظيف، وsite لتوليد موقع توثيق المشروع، والدورة الأساسية default مكونة من مراحل Phases بالترتيب: validate ثم compile ثم test ثم package ثم verify ثم install ثم deploy، والقاعدة المهمة أنك عندما تطلب مرحلة ينفذ Maven كل المراحل التي قبلها بالترتيب، والصورة التالية تبين ذلك:

سبع مراحل من اليمين إلى اليسار: validate وcompile وtest وpackage باللون البرتقالي، ثم verify وinstall وdeploy باللون الرمادي، وفوق البرتقالية قوس مكتوب عليه mvn package. ملف pom.xml يغذي المرحلة الأولى، وinstall يكتب في المستودع المحلي ~/.m2/repository، وdeploy يرفع إلى Maven Central أو Nexus، وسهم download من السحابة إلى المستودع المحلي
عندما تكتب mvn package ينفذ Maven المراحل من validate إلى package

وفي الإعداد أعلاه لاحظ التالي:

  • المرحلة Phase اسم فقط، والعمل الفعلي يقوم به هدف Goal من إضافة Plugin مربوط بها، فمرحلة compile يقوم بها الهدف compiler:compile من الإضافة maven-compiler-plugin، وسوف ترى هذه الأسماء في مخرج التجربة العملية.
  • install لا يعني تثبيت البرنامج على السيرفر، وإنما نسخ الناتج إلى المستودع المحلي Local Repository في ~/.m2/repository حتى تستخدمه مشاريع أخرى على نفس الجهاز.
  • deploy يرفع الناتج إلى مستودع بعيد Remote Repository مثل Nexus أو Artifactory أو Maven Central، وليس إلى سيرفر الإنتاج.

ما الجديد في Maven 3.10؟

صدر Maven 3.10.0 في 27 سبتمبر 2026، وبحسب ملاحظات الإصدار فإن أهم ما يعنينا هنا ثلاثة أمور: الأول أن البناء القابل للتكرار Reproducible Builds أصبح مفعلاً افتراضياً لأن ملف Super POM يعطي قيمة افتراضية للخاصية project.build.outputTimestamp، والثاني أن Super POM لم يعد يحتوي على إدارة نسخ الإضافات Plugin Management، فالمطلوب أن يحدد كل مشروع نسخ إضافاته بنفسه، والثالث أن التحقق من ملف POM أصبح أشد، فأشياء مثل تكرار نفس الاعتمادية مرتين كانت تعطي تحذيراً في 3.9 وأصبحت توقف البناء. وسوف نرى أثر الأمر الأول في التجربة العملية.

Gradle: البناء ككود

Gradle يأخذ من Maven شكل المجلدات ومستودعات المكتبات، لكنه يكتب ملف البناء كبرنامج بلغة Kotlin أو Groovy بدل XML، ويبني على مفهوم المهام Tasks، وكل مهمة لها مدخلات Inputs ومخرجات Outputs معرفة. وبحسب توثيق البناء التدريجي في Gradle 9.8.0 فإن Gradle يأخذ قبل تنفيذ المهمة بصمة Fingerprint لمدخلاتها فيها مسارات الملفات وهاش Hash لمحتوى كل ملف، وبعد التنفيذ يأخذ بصمة للمخرجات، وفي المرة التالية إذا كانت البصمات نفسها يعتبر المهمة محدثة UP-TO-DATE ويتجاوزها. والفرق المهم عن Make أن المقارنة بالمحتوى وليس بالوقت، وسوف نرى في التجربة أن إضافة تعليق Comment في ملف اختبار تعيد ترجمة الملف ولا تعيد تشغيل الاختبارات، لأن الناتج المترجم لم يتغير.

وGradle يحتاج إلى نسخة محددة منه، لذلك الطريقة التي يوصي بها توثيق Gradle Wrapper هي تشغيل البناء عبر سكربت gradlew الموجود في المستودع، والذي ينزل نسخة Gradle المحددة في المشروع قبل التشغيل، وبهذا يبني كل المطورين وخط CI بنفس النسخة. وفي التجربة العملية استخدمنا Docker Image رسمية فيها Gradle 9.8.0 مباشرة لأن نسخة الأداة ثابتة في وسم ال Image نفسه.

dotnet وMSBuild

في عالم .NET الأمر dotnet build هو الواجهة، والذي يعمل تحتها هو MSBuild، وبحسب توثيق dotnet build فإن الأمر يبني المشروع واعتمادياته إلى ملفات فيها كود IL بامتداد dll، ومعها ملف pdb للتصحيح Debugging، وملف deps.json فيه قائمة الاعتماديات، وملف runtimeconfig.json فيه نسخة ال Runtime المطلوبة، ويشغل قبلها dotnet restore تلقائياً لتنزيل حزم NuGet، وتشغيل dotnet build يعادل dotnet msbuild -restore. ووصف المشروع في ملف csproj بصيغة XML، والاعتماديات فيه عناصر PackageReference كما يشرح توثيق NuGet.

أما البناء التدريجي في MSBuild فيعمل بطريقة قريبة من Make، فبحسب توثيق Incremental Builds لكل هدف Target مدخلات Inputs ومخرجات Outputs، ويقارن MSBuild وقت كل ملف مدخل بوقت الملف الناتج المقابل له، فإذا كانت كل المخرجات أحدث من مدخلاتها يتجاوز الهدف.

⚠️
إذا كانت مشاريع الاختبار عندك تستخدم Microsoft.Testing.Platform مثل xUnit v3 وانتقلت إلى .NET 10 SDK، فقد يفشل dotnet test برسالة تقول إن الاختبار عبر هدف VSTest لم يعد مدعوماً، والحل كما في توثيق dotnet test إضافة قسم test إلى ملف global.json فيه "runner": "Microsoft.Testing.Platform"، وسوف ترى هذا الملف في التجربة العملية.

npm scripts والمجمعات Bundlers باختصار

في مشاريع JavaScript لا توجد ترجمة بالمعنى الذي رأيناه في Java، لكن توجد خطوة بناء في أغلب المشاريع الحديثة، لأن الكود يكتب بـ TypeScript أو JSX أو بمئات الملفات الصغيرة، ويحتاج إلى تحويل وتجميع في ملفات قليلة يفهمها المتصفح. والأداة الأولى هنا هي خاصية scripts في ملف package.json، والتي يشرحها توثيق npm scripts، فتكتب فيها أوامر مثل "build": "vite build" وتشغلها بـ npm run build، وهي في الحقيقة أقرب إلى Makefile فيه أوامر مختصرة منها إلى أداة بناء تعرف الاعتماديات بين الملفات. والعمل الفعلي تقوم به المجمعات Bundlers مثل Vite وesbuild، والتي تتبع ملفات import من نقطة البداية وتخرج مجلداً مثل dist فيه الملفات النهائية.

وفي خط CI استخدم npm ci بدل npm install، والسبب كما في توثيق npm ci أنه يشترط وجود ملف package-lock.json، ويتوقف بخطأ إذا كان لا يطابق package.json بدل أن يعدله، ويحذف مجلد node_modules قبل التثبيت، ولا يكتب أبداً في package.json أو ملف القفل.

Go وCargo: البناء جزء من اللغة

اللغات الأحدث لم تترك البناء لأداة خارجية، ففي Go الأمر go build يعرف الحزم والاعتماديات من ملف go.mod، ويحفظ نتائج الترجمة في ذاكرة البناء Build Cache، وبحسب توثيق الأمر go فإن مكانها الافتراضي مجلد اسمه go-build داخل مجلد ال Cache للمستخدم ويمكن تغييره بالمتغير GOCACHE. ومن جهة التكرار فإن Russ Cox كتب في مقال Perfectly Reproducible, Verified Go Toolchains في أغسطس 2023 أن أدوات Go نفسها أصبحت منذ الإصدار 1.21 قابلة للتكرار تماماً، أي أن المدخل الوحيد المؤثر في بنائها هو الكود المصدري.

وفي Rust تقوم Cargo بالدورين معاً: مدير الحزم وأداة البناء، فالأمر cargo build ينزل الاعتماديات ويبنيها ويبني المشروع، والنسخ الدقيقة تحفظ في ملف Cargo.lock الذي يقول عنه دليل Cargo إنه يحتوي معلومات دقيقة عن الاعتماديات وتحدثه Cargo ولا يعدل يدوياً، وإن كان لديك شك فضعه في نظام إدارة النسخ مثل Git.

حل الاعتماديات وملفات القفل باختصار

عندما تكتب في pom.xml أنك تحتاج junit-jupiter بالإصدار 6.1.3، فأنت لا تطلب مكتبة واحدة، وإنما شجرة كاملة، وسوف ترى في التجربة العملية أن هذه المكتبة الواحدة جلبت معها ثماني مكتبات أخرى، وأداة البناء هي التي تقرر أي نسخة تأخذ إذا طلبت مكتبتان نسختين مختلفتين من نفس الاعتمادية، ولكل أداة قاعدتها، ففي Maven مثلاً يشرح توثيق آلية الاعتماديات قاعدة «الأقرب يفوز» Nearest Definition.

وملف القفل Lock File يسجل النسخ الدقيقة التي اختيرت، حتى يحصل كل مطور وكل بناء في CI على نفس الشجرة، ومثاله package-lock.json في npm وCargo.lock في Rust وgo.sum في Go، وفي Gradle يمكن تفعيل قفل الاعتماديات Dependency Locking. أما أمان هذه الشجرة، أي من أين تأتي الحزم وكيف تكتشف الحزم الخبيثة والثغرات المعروفة فيها، فهو موضوع مستقل شرحناه في مقال أمن سلسلة التوريد البرمجية ومقال فحص الاعتماديات SCA، ولن نكرره هنا.

البناء التدريجي Incremental Build وذاكرة البناء Build Cache

البناء الكامل في كل مرة هو الحل البسيط، وهو صحيح دائماً لكنه بطيء، لذلك تحاول كل أداة أن تعرف ما الذي تغير وتبني ذلك فقط، وهذا هو البناء التدريجي Incremental Build. وهناك طريقتان للمعرفة: المقارنة بوقت التعديل كما في Make وMSBuild، وهي سريعة لكنها تخطئ إذا تغيرت الأوقات دون تغير المحتوى، والمقارنة بهاش المحتوى كما في Gradle وGo، وهي أدق. والصورة التالية تبين كيف يقرر Gradle مصير كل مهمة:

في اليمين لوحة Task inputs فيها ملفات *.java وclasspath وcompiler flags وJDK، يخرج منها هاش fingerprint. الهاش يدخل معيناً مكتوب عليه compare، ويخرج منه ثلاثة أسهم: أخضر إلى UP-TO-DATE إذا كان نفس الهاش في آخر تشغيل، وأزرق إلى FROM-CACHE إذا كان في ال Cache، وبرتقالي إلى EXECUTED لهاش جديد ثم تخزن المخرجات. وفي اليسار قاعدة بيانات Build cache محلي أو مشترك يستخدمه developer وCI runner
البصمة تقرر: لا عمل، أو نسخ من ال Cache، أو تنفيذ ثم تخزين

وذاكرة البناء Build Cache تذهب خطوة أبعد من البناء التدريجي، فالبناء التدريجي يعرف آخر تشغيل في نفس المجلد فقط، أما ال Build Cache فيحفظ مخرجات كل مهمة مع بصمة مدخلاتها، فإذا ظهرت نفس البصمة مرة أخرى في أي وقت أو في أي مجلد أو حتى على جهاز آخر عبر Cache مشترك Remote Cache تنسخ المخرجات بدل التنفيذ، وهذا ما يعرفه توثيق Gradle Build Cache بأنه آلية تحفظ مخرجات البناء محلياً أو عن بعد وتعيد استخدامها عندما تكون المدخلات لم تتغير. وفي التجربة العملية سوف ترى FROM-CACHE بعد gradle clean مباشرة.

وقد يتساءل البعض: هل يمكن أن يعطيني ال Cache ناتجاً خاطئاً؟ نعم، إذا كانت المهمة تعتمد على شيء لم يعلن كمدخل، مثل متغير بيئة Environment Variable تقرؤه أداة خارجية، أو ملف خارج المشروع، وعندها تتطابق البصمة ويختلف الناتج الحقيقي، لذلك إذا كتبت مهام بناء خاصة بك فعرف كل مدخلاتها، وإذا شككت في ناتج غريب فابن مرة بدون Cache لتتأكد، مثل dotnet build --no-incremental أو gradle build --rerun-tasks.

البناء القابل للتكرار Reproducible Build

التعريف الذي يعتمده مشروع Reproducible Builds في صفحة التعريف هو أن البناء قابل للتكرار إذا استطاع أي طرف، بنفس الكود المصدري وبيئة البناء وتعليمات البناء، أن ينتج نسخاً مطابقة بايت ببايت Bit-by-bit من كل الملفات الناتجة المحددة. والفائدة الأمنية مباشرة: إذا بنى طرفان مستقلان نفس الكود وحصلا على نفس الهاش، فأنت تعرف أن الملف المنشور لم يحقن فيه شيء أثناء البناء، وهذه نقطة يعود إليها مقال أمن سلسلة التوريد عند الكلام عن اختراق نظام البناء نفسه.

في اليمين لوحة نفس المدخلات الثلاثة: source code وenvironment وinstructions، تخرج منها أسهم إلى builder A وbuilder B، وكل واحد ينتج صندوقاً أخضر عليه sha256 b8ccfef595ab، وبينهما علامة صح ونص نسخة مطابقة بايت ببايت. وفي الأسفل لوحة ما الذي يتسرب إلى الناتج فيغير الهاش: timestamps وbuild path وtool versions وfile order
نفس المدخلات تعطي نفس الهاش، وأي تسرب من البيئة يكسر ذلك

والصعوبة أن البيئة تتسرب إلى الناتج بطرق لا تنتبه لها، وأشهرها أربعة:

  • الوقت Timestamps: ملفات jar وzip تحفظ وقت كل ملف داخلها، فإذا بنيت مرتين في دقيقتين مختلفتين يختلف الهاش، والحل المتعارف عليه وقت ثابت مثل المتغير SOURCE_DATE_EPOCH، وفي Maven الخاصية project.build.outputTimestamp التي يشرحها دليل Maven للبناء القابل للتكرار.
  • المسار Build Path: بعض المترجمات تكتب المسار الكامل للمجلد داخل الناتج، وتوثيق خيار Deterministic في مترجم C# يذكر مسار المجلد الحالي ضمن المدخلات التي تؤثر على الناتج، وسوف نرى ذلك في التجربة العملية، وفي Go الخيار -trimpath يحذف مسارات الملفات من الناتج.
  • نسخ الأدوات Tool Versions: نسخة مختلفة من المترجم أو من إضافة Plugin قد تنتج bytecode مختلفاً، لذلك ثبت نسخة ال Docker Image ونسخ الإضافات.
  • ترتيب الملفات File Order: ترتيب الملفات في الأرشيف قد يتبع ترتيب نظام الملفات الذي يختلف من جهاز لآخر، وبحسب توثيق Gradle للتعامل مع الملفات فإن مهام الأرشيف مثل Jar وZip تنتج منذ Gradle 9.0.0 أرشيفات قابلة للتكرار افتراضياً بترتيب ثابت للملفات وأوقات ثابتة وصلاحيات ثابتة.

والخبر الجيد أن الأدوات الكبيرة جعلت ذلك افتراضياً تقريباً في 2026: Gradle 9 للأرشيفات، وMaven 3.10 بقيمة افتراضية للوقت، و.NET بخاصية Deterministic المفعلة افتراضياً كما يذكر توثيق المترجم أعلاه، ويبقى عليك أن تثبت البيئة نفسها، وأسهل طريقة لذلك أن تبني داخل Container بنسخة Image محددة وفي مسار ثابت.

البناء داخل خط CI

على جهاز المطور يكون البناء سريعاً لأن الذاكرة المحلية ممتلئة بالمكتبات والمخرجات السابقة، أما في CI فكل مهمة تبدأ عادةً من Container جديد فارغ، وإذا لم تنتبه سوف ينزل كل بناء نفس المكتبات من الإنترنت في كل مرة. والصورة التالية تبين أين يحدث البناء في خط CI:

تدفق مرقم من اليمين إلى اليسار: 1 لابتوب git push، 2 سيرفر Gitea يطلق ال Workflow، 3 لوحة RUNNER فيها Container maven:3.10.0 بنسخة أدوات ثابتة وقاعدة بيانات ~/.m2 cache وعلامة صح mvn verify، 4 صندوق artifact or Image مع رقم الإصدار والهاش يرفع إلى registry، 5 خط أخضر ينقل نفس الملف إلى staging ثم production دون إعادة بناء
ابن مرة واحدة في CI بأدوات ثابتة، ثم انقل نفس الناتج إلى كل البيئات

وفيما يلي القواعد التي تجعل البناء في CI سريعاً وموثوقاً:

  1. ابن داخل Container بنسخة محددة مثل maven:3.10.0-eclipse-temurin-25، ولا تستخدم وسماً متحركاً مثل latest، والسبب أن الوسم المتحرك يغير نسخة الأداة دون أن يتغير الكود، فيتغير الناتج أو يفشل البناء فجأة.
  2. احفظ ذاكرة الاعتماديات بين التشغيلات: مجلد ~/.m2 في Maven، و~/.gradle في Gradle، و~/.nuget/packages في .NET، إما عبر آلية ال Cache في منصة CI، أو عبر Volume في ال Runner الخاص بك، أو عبر Cache Mounts في Docker BuildKit إذا كان البناء داخل Dockerfile.
  3. شغل الاختبارات كجزء من البناء، ولا تنشر ناتجاً تجاوز الاختبارات بخيار مثل -DskipTests.
  4. ابن مرة واحدة وانقل الناتج، فلا تعيد البناء لكل بيئة، والسبب أن البناء الثاني قد يختلف عن الأول، وقد شرحنا هذه الفكرة في مقال البيئات وGitOps.
  5. إذا كان الناتج Docker Image فاستخدم البناء متعدد المراحل Multi-stage Build: مرحلة فيها SDK كامل تبني، ومرحلة أخيرة صغيرة فيها ال Runtime والناتج فقط.

وإذا كنت تشغل CI على سيرفرك فقد شرحنا تجهيز Runner وأول Workflow يبني Image ويرفعها في دليل Gitea Actions، وأين تضع فحوص الأمان بجانب البناء في مقال DevSecOps للمبتدئين.

تجربة عملية: نفس البرنامج بثلاث أدوات بناء

الآن سوف نبني برنامجاً صغيراً واحداً بـ Maven ثم Gradle ثم dotnet، والبرنامج يحسب السعر بعد ضريبة القيمة المضافة 15% بالهللات (حتى نتجنب أخطاء الأرقام العشرية)، ومعه اختباران، وكل بناء يعمل داخل Container بنسخة محددة من الأداة، والذاكرة المحلية للمكتبات في Volume منفصل حتى نرى الفرق بين البناء الأول والبناء التالي. والأوقات التي سوف تراها حقيقية من جهاز واحد، لكنها توضيحية فقط ولا تصلح للمقارنة بين الأدوات، لأن كل أداة تنزل مكتبات مختلفة وتشغل JVM أو Runtime مختلفاً.

📌
في التجربة نشغل Container ينتظر بالأمر sleep infinity، ثم ننسخ الملفات إليه بـ docker cp وننفذ البناء بـ docker exec، وبهذا تعمل نفس الأوامر على Linux وWindows وmacOS دون مشكلات المسارات المشتركة، وفي مشروعك الحقيقي يكفي docker run --rm -v "$PWD":/src ….

Maven: البناء الأول والثاني

المثال التالي يبين ملف pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>io.arabroot</groupId>
  <artifactId>vat</artifactId>
  <version>1.0.0</version>
  <packaging>jar</packaging>

  <properties>
    <maven.compiler.release>25</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>6.1.3</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
</project>

والكود في الملف src/main/java/io/arabroot/Vat.java:

package io.arabroot;

public final class Vat {
    public static final int RATE_PERCENT = 15;

    // amounts in halalas (1 riyal = 100 halalas) to avoid floating point errors
    public static long total(long net) {
        if (net < 0) throw new IllegalArgumentException("net must be >= 0");
        return net + Math.round(net * RATE_PERCENT / 100.0);
    }

    public static void main(String[] args) {
        long net = Long.parseLong(args.length > 0 ? args[0] : "10000");
        System.out.println("net=" + net + " total=" + total(net));
    }
}

والاختبار في الملف src/test/java/io/arabroot/VatTest.java:

package io.arabroot;

import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;

class VatTest {
    @Test
    void addsFifteenPercent() {
        assertEquals(11500, Vat.total(10000));
    }

    @Test
    void rejectsNegative() {
        assertThrows(IllegalArgumentException.class, () -> Vat.total(-1));
    }
}

الآن سوف نقوم بتشغيل ال Container ونسخ المشروع إليه، ثم البناء الأول والذاكرة المحلية فارغة:

docker run -d --name lab-build-maven -v lab-build-m2:/root/.m2 -w /src maven:3.10.0-eclipse-temurin-25 sleep infinity
docker cp . lab-build-maven:/src
docker exec lab-build-maven mvn -v
docker exec lab-build-maven bash -c 'time mvn -B package'

والمخرج سوف يكون كما يلي، مختصراً: أبقينا سطرين من أسطر التنزيل الكثيرة (182 ملفاً) وحذفنا الأسطر الفارغة:

Apache Maven 3.10.0 (c43a36b8d67be7e0805a411bc0898af1a51f5472)
Maven home: /usr/share/maven
Java version: 25.0.4.1, vendor: Eclipse Adoptium, runtime: /opt/java/openjdk
Default locale: en_US, platform encoding: UTF-8, time zone: Etc/UTC
OS name: "linux", version: "5.15.167.4-microsoft-standard-wsl2", arch: "amd64", family: "unix"

[INFO] Downloaded from central: https://repo.maven.apache.org/maven2/org/apache/maven/plugins/maven-resources-plugin/3.5.0/maven-resources-plugin-3.5.0.pom (7.7 kB at 24 kB/s)
[INFO] Downloaded from central: https://repo.maven.apache.org/maven2/org/junit/jupiter/junit-jupiter-api/6.1.3/junit-jupiter-api-6.1.3.jar (313 kB at 498 kB/s)
[INFO] --- resources:3.5.0:resources (default-resources) @ vat ---
[INFO] skip non existing resourceDirectory /src/src/main/resources
[INFO] --- compiler:3.16.0:compile (default-compile) @ vat ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 1 source file with javac [debug release 25] to target/classes
[INFO] --- resources:3.5.0:testResources (default-testResources) @ vat ---
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ vat ---
[INFO] Compiling 1 source file with javac [debug release 25] to target/test-classes
[INFO] --- surefire:3.6.0:test (default-test) @ vat ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO] Running io.arabroot.VatTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.282 s -- in io.arabroot.VatTest
[INFO] --- jar:3.5.1:jar (default-jar) @ vat ---
[INFO] Building jar: /src/target/vat-1.0.0.jar
[INFO] BUILD SUCCESS
[INFO] Total time:  01:22 min

real	1m31.769s

في المخرج أعلاه لاحظ التالي:

  • كل سطر يبدأ بـ --- هو هدف Goal من إضافة مربوط بمرحلة: resources ثم compiler:compile ثم surefire:test ثم jar:jar، وهذا هو ترتيب دورة الحياة الذي رأيناه في الصورة.
  • نسخ الإضافات مثل compiler:3.16.0 وsurefire:3.6.0 جاءت من الإعداد الافتراضي لأننا لم نحددها، وفي مشروع حقيقي حددها في قسم pluginManagement، والسبب أن Maven 3.10 حذف إدارة نسخ الإضافات من Super POM كما ذكرنا، وتحديدها يجعل البناء لا يتغير مع نسخة Maven.
  • أغلب الدقيقة والنصف ذهبت في تنزيل 182 ملفاً هي الإضافات والمكتبات، والترجمة نفسها أجزاء من الثانية.

وشجرة الاعتماديات التي جاءت من سطر واحد في pom.xml تراها بالأمر التالي:

docker exec lab-build-maven mvn -B -o dependency:tree
[INFO] io.arabroot:vat:jar:1.0.0
[INFO] \- org.junit.jupiter:junit-jupiter:jar:6.1.3:test
[INFO]    +- org.junit.jupiter:junit-jupiter-api:jar:6.1.3:test
[INFO]    |  +- org.opentest4j:opentest4j:jar:1.3.0:test
[INFO]    |  +- org.junit.platform:junit-platform-commons:jar:6.1.3:test
[INFO]    |  +- org.apiguardian:apiguardian-api:jar:1.1.2:test
[INFO]    |  \- org.jspecify:jspecify:jar:1.0.0:test
[INFO]    +- org.junit.jupiter:junit-jupiter-params:jar:6.1.3:test
[INFO]    \- org.junit.jupiter:junit-jupiter-engine:jar:6.1.3:test
[INFO]       \- org.junit.platform:junit-platform-engine:jar:6.1.3:test

الآن البناء الثاني بعد clean والذاكرة المحلية ممتلئة، ثم بناء ثالث دون أي تغيير، ثم تشغيل الناتج:

docker exec lab-build-maven bash -c 'du -sh /root/.m2/repository; time mvn -B -q clean package'
docker exec lab-build-maven bash -c 'time mvn -B package | grep -E "compiler|Nothing to compile|Total time"'
docker exec lab-build-maven java -cp target/vat-1.0.0.jar io.arabroot.Vat 10000
25M	/root/.m2/repository

real	0m29.310s

[INFO] --- compiler:3.16.0:compile (default-compile) @ vat ---
[INFO] Nothing to compile - all classes are up to date.
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ vat ---
[INFO] Nothing to compile - all classes are up to date.
[INFO] Total time:  13.348 s

real	0m20.534s

net=10000 total=11500

لاحظ أن البناء نزل من دقيقة ونصف إلى نصف دقيقة لأن 25 ميجابايت من المكتبات أصبحت في ال Volume، وأن البناء دون تغيير لم يترجم شيئاً (Nothing to compile) لكنه أخذ عشرين ثانية، والسبب أن Maven يعيد تشغيل الاختبارات وتحزيم ال jar في كل مرة، فالبناء التدريجي في Maven محدود بالترجمة، وهذا من أهم الفروق عن Gradle كما سيأتي.

نكسر البناء: اختبار يفشل

الآن سوف نغير نسبة الضريبة في الكود إلى 14% عن قصد، ونرى ماذا يحدث للبناء:

docker exec lab-build-maven bash -c 'sed -i "s/RATE_PERCENT = 15/RATE_PERCENT = 14/" src/main/java/io/arabroot/Vat.java; mvn -B -o clean package 2>&1 | grep -E "ERROR|FAIL|Tests run|BUILD|expected" | head -12; ls target/*.jar'
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.867 s <<< FAILURE! -- in io.arabroot.VatTest
[ERROR] io.arabroot.VatTest.addsFifteenPercent -- Time elapsed: 0.293 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <11500> but was: <11400>
[ERROR] Failures:
[ERROR]   VatTest.addsFifteenPercent:9 expected: <11500> but was: <11400>
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
[ERROR] BUILD FAILURE
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.6.0:test (default-test) on project vat: There are test failures.
[ERROR]
[ERROR] See /src/target/surefire-reports for the individual test results.
[ERROR] See dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream.
[ERROR] -> [Help 1]
ls: cannot access 'target/*.jar': No such file or directory

وهذا بالضبط ما تريده من أداة البناء: فشل الاختبار أوقف دورة الحياة عند مرحلة test، فلم تصل إلى package ولم يخرج أي ملف jar يمكن أن ينشر بالخطأ، وسطر expected: <11500> but was: <11400> يخبرك بالقيمة المتوقعة والقيمة الفعلية ورقم السطر في الاختبار. بعدها أعدنا القيمة إلى 15.

هل بناء Maven قابل للتكرار؟

الآن سوف نبني مرتين بينهما ثلاث ثوان ونقارن الهاش، دون أن نضيف أي إعداد خاص في pom.xml:

docker exec lab-build-maven bash -c 'mvn -B -q -o clean package -DskipTests && sha256sum target/vat-1.0.0.jar && sleep 3 && mvn -B -q -o clean package -DskipTests && sha256sum target/vat-1.0.0.jar'
docker exec lab-build-maven jar tvf target/vat-1.0.0.jar
docker exec lab-build-maven bash -c 'mvn -B -q help:effective-pom -Doutput=/tmp/ep.xml >/dev/null 2>&1; grep -n outputTimestamp /tmp/ep.xml'
67b79ece197d767cebe19cbaff91934f654e21ac5c53742e1be123bda80f66e7  target/vat-1.0.0.jar
67b79ece197d767cebe19cbaff91934f654e21ac5c53742e1be123bda80f66e7  target/vat-1.0.0.jar
     0 Fri Feb 01 00:00:00 UTC 1980 META-INF/
    99 Fri Feb 01 00:00:00 UTC 1980 META-INF/MANIFEST.MF
     0 Fri Feb 01 00:00:00 UTC 1980 io/
     0 Fri Feb 01 00:00:00 UTC 1980 io/arabroot/
     0 Fri Feb 01 00:00:00 UTC 1980 META-INF/maven/
     0 Fri Feb 01 00:00:00 UTC 1980 META-INF/maven/io.arabroot/
     0 Fri Feb 01 00:00:00 UTC 1980 META-INF/maven/io.arabroot/vat/
  1456 Fri Feb 01 00:00:00 UTC 1980 io/arabroot/Vat.class
   806 Fri Feb 01 00:00:00 UTC 1980 META-INF/maven/io.arabroot/vat/pom.xml
    49 Fri Feb 01 00:00:00 UTC 1980 META-INF/maven/io.arabroot/vat/pom.properties
20:    <project.build.outputTimestamp>1980-02-01T00:00:00Z</project.build.outputTimestamp>

الهاش متطابق، وكل الملفات داخل ال jar بتاريخ 1 فبراير 1980 بدل وقت البناء، والسبب هو القيمة الافتراضية التي أضافها Maven 3.10 للخاصية project.build.outputTimestamp كما ترى في السطر الأخير. وفي مشروعك الحقيقي ضع قيمة لهذه الخاصية في pom.xml بنفسك (عادةً تاريخ آخر commit أو تاريخ الإصدار) بدل الاعتماد على القيمة الافتراضية، وتحقق بالأمر mvn clean verify artifact:compare كما يشرح دليل Maven للبناء القابل للتكرار.

Gradle: UP-TO-DATE وFROM-CACHE

نفس مجلد src بدون تغيير، ومعه ملفان بدل pom.xml، الأول settings.gradle.kts:

rootProject.name = "vat"

والثاني build.gradle.kts:

plugins {
    java
}

group = "io.arabroot"
version = "1.0.0"

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(25)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:6.1.3")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.test {
    useJUnitPlatform()
}

الآن البناء الأول:

docker run -d --name lab-build-gradle -v lab-build-gradle-home:/home/gradle/.gradle -w /src gradle:9.8.0-jdk25 sleep infinity
docker cp . lab-build-gradle:/src
docker exec lab-build-gradle bash -c 'time gradle build --console=plain'
Starting a Gradle Daemon (subsequent builds will be faster)
> Task :compileJava
> Task :processResources NO-SOURCE
> Task :classes
> Task :jar
> Task :assemble
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test
> Task :check
> Task :build

BUILD SUCCESSFUL in 1m 41s
4 actionable tasks: 4 executed

real	1m42.067s

ثم ثلاث تجارب متتالية: بناء دون تغيير، ثم إضافة تعليق Comment فقط في ملف الاختبار، ثم clean مع ذاكرة البناء --build-cache مرتين:

docker exec lab-build-gradle bash -c 'gradle build --console=plain
echo "// a comment only" >> src/test/java/io/arabroot/VatTest.java; gradle build --console=plain
gradle clean build --build-cache --console=plain -q; sha256sum build/libs/vat-1.0.0.jar
gradle clean build --build-cache --console=plain; sha256sum build/libs/vat-1.0.0.jar'
> Task :compileJava UP-TO-DATE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :jar UP-TO-DATE
> Task :assemble UP-TO-DATE
> Task :compileTestJava UP-TO-DATE
> Task :processTestResources NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :test UP-TO-DATE
> Task :check UP-TO-DATE
> Task :build UP-TO-DATE

BUILD SUCCESSFUL in 3s
4 actionable tasks: 4 up-to-date

> Task :compileJava UP-TO-DATE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :jar UP-TO-DATE
> Task :assemble UP-TO-DATE
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test UP-TO-DATE
> Task :check UP-TO-DATE
> Task :build UP-TO-DATE

BUILD SUCCESSFUL in 3s
4 actionable tasks: 1 executed, 3 up-to-date

b8ccfef595abf5d48bd6c95543b74cedf6ddb80816dc4e95dcfd815f5bba5531  build/libs/vat-1.0.0.jar

> Task :clean
> Task :compileJava FROM-CACHE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :jar
> Task :assemble
> Task :compileTestJava FROM-CACHE
> Task :processTestResources NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :test FROM-CACHE
> Task :check UP-TO-DATE
> Task :build

BUILD SUCCESSFUL in 3s
5 actionable tasks: 2 executed, 3 from cache
b8ccfef595abf5d48bd6c95543b74cedf6ddb80816dc4e95dcfd815f5bba5531  build/libs/vat-1.0.0.jar

في المخرج أعلاه لاحظ التالي:

  • البناء دون تغيير أخذ 3 ثوان وكل المهام UP-TO-DATE بما فيها test، على عكس Maven الذي أعاد تشغيل الاختبارات في كل مرة.
  • بعد إضافة التعليق أعاد Gradle ترجمة ملف الاختبار فقط (compileTestJava)، لكن مهمة test بقيت UP-TO-DATE، والسبب أن التعليق لا يظهر في ال bytecode، فالمخرجات المترجمة التي هي مدخلات مهمة الاختبار لم يتغير هاشها، وهذا هو الفرق بين المقارنة بالمحتوى والمقارنة بالوقت.
  • بعد clean جاءت نتائج الترجمة والاختبار من ال Build Cache (FROM-CACHE) فلم تنفذ الاختبارات من جديد، وأعيد تحزيم ال jar فقط.
  • الهاش b8ccfef5… هو نفسه في المرتين، وهو أيضاً نفس الهاش الذي حصلنا عليه في Container سابق حذفناه وبدأنا من جديد، أي أن ال jar قابل للتكرار بين بيئتين نظيفتين دون أي إعداد إضافي، وهذا أثر الأرشيفات القابلة للتكرار افتراضياً في Gradle 9.

dotnet: نفس البرنامج بلغة C#

المشروع هنا مكون من مشروعين: البرنامج ومشروع الاختبارات، والمثال التالي يبين الملف Vat/Vat.csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <Version>1.0.0</Version>
  </PropertyGroup>
</Project>

والكود في الملف Vat/Vat.cs:

namespace ArabRoot;

public static class Vat
{
    public const int RatePercent = 15;

    // amounts in halalas (1 riyal = 100 halalas)
    public static long Total(long net)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(net);
        return net + (long)Math.Round(net * RatePercent / 100.0, MidpointRounding.AwayFromZero);
    }

    public static void Main(string[] args)
    {
        long net = args.Length > 0 ? long.Parse(args[0]) : 10000;
        Console.WriteLine($"net={net} total={Total(net)}");
    }
}

ومشروع الاختبار Vat.Tests/Vat.Tests.csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <OutputType>Exe</OutputType>
    <IsPackable>false</IsPackable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="xunit.v3" Version="4.0.1" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="../Vat/Vat.csproj" />
  </ItemGroup>
</Project>

والاختبار في الملف Vat.Tests/VatTests.cs:

using Xunit;

namespace ArabRoot.Tests;

public class VatTests
{
    [Fact]
    public void AddsFifteenPercent() => Assert.Equal(11500, Vat.Total(10000));

    [Fact]
    public void RejectsNegative() =>
        Assert.Throws<ArgumentOutOfRangeException>(() => Vat.Total(-1));
}

وملف global.json في المجلد الرئيسي حتى يعمل dotnet test بوضع Microsoft.Testing.Platform:

{
  "test": {
    "runner": "Microsoft.Testing.Platform"
  }
}

الآن سوف نقوم بتشغيل ال Container وإنشاء ملف ال Solution بصيغة slnx ثم البناء الأول:

docker run -d --name lab-build-dotnet -v lab-build-nuget:/root/.nuget/packages -w /src -e DOTNET_CLI_TELEMETRY_OPTOUT=1 -e DOTNET_NOLOGO=1 mcr.microsoft.com/dotnet/sdk:10.0.401 sleep infinity
docker cp . lab-build-dotnet:/src
docker exec lab-build-dotnet bash -c 'dotnet new sln -n Vat --format slnx && dotnet sln add Vat/Vat.csproj Vat.Tests/Vat.Tests.csproj; time dotnet build -c Release --tl:off'

وآخر أسطر المخرج:

  Determining projects to restore...
  Restored /src/Vat/Vat.csproj (in 292 ms).
  Restored /src/Vat.Tests/Vat.Tests.csproj (in 30.21 sec).
  Vat -> /src/Vat/bin/Release/net10.0/Vat.dll
  Vat.Tests -> /src/Vat.Tests/bin/Release/net10.0/Vat.Tests.dll

Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed 00:00:43.55

real	0m44.549s

ثم الاختبارات، ثم بناء دون تغيير، ثم الملفات الناتجة وحجم ذاكرة الحزم:

docker exec lab-build-dotnet bash -c 'dotnet test -c Release --no-build'
docker exec lab-build-dotnet bash -c 'time dotnet build -c Release --tl:off 2>&1 | tail -5'
docker exec lab-build-dotnet bash -c 'dotnet Vat/bin/Release/net10.0/Vat.dll 10000; ls Vat/bin/Release/net10.0/; du -sh /root/.nuget/packages; ls /root/.nuget/packages | wc -l'
Running tests from /src/Vat.Tests/bin/Release/net10.0/Vat.Tests.dll (net10.0|x64)
/src/Vat.Tests/bin/Release/net10.0/Vat.Tests.dll (net10.0|x64) passed (1s 388ms)

Test run summary: Passed!
  total: 2
  failed: 0
  succeeded: 2
  skipped: 0
  duration: 2s 344ms

Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed 00:00:05.86

real	0m6.602s

net=10000 total=11500
Vat
Vat.deps.json
Vat.dll
Vat.pdb
Vat.runtimeconfig.json
39M	/root/.nuget/packages
17

لاحظ أن الملفات الناتجة هي نفسها التي ذكرها توثيق dotnet build: ملف dll وملف pdb وملف deps.json وملف runtimeconfig.json، ومعها ملف تنفيذي صغير Vat يشغل ال dll، وأن الحزمة الواحدة xunit.v3 جلبت معها 17 حزمة بحجم 39 ميجابايت، وأن الاستعادة Restore أخذت ثلاثين ثانية من أصل 44.

نكسر التكرار: نفس الكود في مجلد آخر

خاصية Deterministic مفعلة افتراضياً في .NET، فهل يكفي ذلك؟ الآن سوف نبني نفس المشروع مرتين في /src، ثم مرة في نسخة منه في /build2، ونطبع الهاش ومسار ملف ال pdb المكتوب داخل ال dll:

docker exec lab-build-dotnet bash -c 'cp -r /src /build2; for d in /src /src /build2; do cd $d; dotnet build Vat/Vat.csproj -c Release --no-incremental -v q --tl:off >/dev/null; echo "$d: $(sha256sum Vat/bin/Release/net10.0/Vat.dll | cut -c1-16)  $(grep -a -o "[/a-zA-Z0-9_.]*Vat\.pdb" Vat/bin/Release/net10.0/Vat.dll)"; done'
/src: 234b072f795185e1  /src/Vat/obj/Release/net10.0/Vat.pdb
/src: 234b072f795185e1  /src/Vat/obj/Release/net10.0/Vat.pdb
/build2: dc7fa4f236a99272  /build2/Vat/obj/Release/net10.0/Vat.pdb

في نفس المجلد الهاش متطابق، وفي مجلد آخر تغير الهاش مع أن الكود والأداة نفسها، والسبب ظاهر في العمود الثاني: المسار الكامل لملف ال pdb مكتوب داخل ال dll، وهذا ما يقصده توثيق المترجم عندما يضع مسار المجلد الحالي ضمن مدخلات الناتج. والحل العملي أن تبني دائماً في مسار ثابت داخل Container (مثل /src في كل بناء)، أو أن تستخدم خيارات تعيين المسارات في المترجم إذا كان لا بد من مسارات مختلفة.

Make وGo باختصار

أخيراً المثال الصغير بلغة C الذي رأيته في صورة Make، وملف Makefile كما يلي (الأسطر التي تحت كل قاعدة تبدأ بحرف Tab وليس بمسافات):

CC = gcc
CFLAGS = -O2 -Wall

vat: main.o vat.o
	$(CC) -o vat main.o vat.o

main.o: main.c vat.h
	$(CC) $(CFLAGS) -c main.c

vat.o: vat.c vat.h
	$(CC) $(CFLAGS) -c vat.c

clean:
	rm -f vat *.o

.PHONY: clean

والملفات الثلاثة vat.h وvat.c وmain.c:

/* vat.h */
long vat_total(long net);

/* vat.c */
#include "vat.h"

long vat_total(long net) { return net + (net * 15 + 50) / 100; }

/* main.c */
#include <stdio.h>
#include "vat.h"

int main(void) { printf("net=10000 total=%ld\n", vat_total(10000)); return 0; }

نشغلها في Image الرسمية لـ Go لأنها تحتوي على gcc وmake:

docker run -d --name lab-build-make -w /src golang:1.27.1-bookworm sleep infinity
docker cp . lab-build-make:/src
docker exec lab-build-make bash -c 'make; ./vat; make; sleep 1; touch vat.c; make; touch vat.h; make'
gcc -O2 -Wall -c main.c
gcc -O2 -Wall -c vat.c
gcc -o vat main.o vat.o
net=10000 total=11500
make: 'vat' is up to date.
gcc -O2 -Wall -c vat.c
gcc -o vat main.o vat.o
gcc -O2 -Wall -c main.c
gcc -O2 -Wall -c vat.c
gcc -o vat main.o vat.o

البناء الأول ترجم الملفين وربطهما، والثاني لم يفعل شيئاً، وبعد touch vat.c أعاد ترجمة vat.c فقط ثم الربط، وبعد touch vat.h أعاد ترجمة الملفين لأن كليهما يعتمد عليه، وهذا كله دون أن يتغير حرف واحد في المحتوى، لأن touch يغير الوقت فقط وMake يقرأ الوقت.

وفي Go نفس البرنامج في ملفين، go.mod:

module example.com/vat

go 1.27

وmain.go:

package main

import "fmt"

func total(net int64) int64 { return net + (net*15+50)/100 }

func main() { fmt.Printf("net=10000 total=%d\n", total(10000)) }

والتجربة: بناء بذاكرة بناء فارغة، ثم بناء ثان، ثم البناء في مجلدين مختلفين مع -trimpath وبدونه:

docker exec lab-build-make bash -c 'cp -r /go-src /tmp/other; go clean -cache; cd /go-src; time go build -o vat .; rm vat; time go build -o vat .; ./vat
for d in /go-src /tmp/other; do cd $d; go build -o vat . && echo "$d default:   $(sha256sum vat | cut -c1-16)"; go build -trimpath -o vat . && echo "$d -trimpath: $(sha256sum vat | cut -c1-16)"; done'
real	0m25.343s

real	0m0.528s
net=10000 total=11500
/go-src default:   9accaa7a1cd41e6b
/go-src -trimpath: e7d2f2e2972dae34
/tmp/other default:   229d1e91e93fcb6f
/tmp/other -trimpath: e7d2f2e2972dae34

البناء الأول أخذ 25 ثانية لأن go clean -cache حذف حتى نتائج ترجمة المكتبة القياسية Standard Library، والثاني نصف ثانية من ال Build Cache، وفي المجلدين اختلف الهاش بدون -trimpath وتطابق معه، وهي نفس مشكلة المسار التي رأيناها في .NET.

النتائج في جدول واحد

الأداةالبناء الأولبعد امتلاء ال Cacheدون أي تغييرتكرار الهاش
Maven 3.10.01m31s مع 182 تنزيلاً29s (مع clean)20s، الاختبارات تعمل مرة أخرىمتطابق افتراضياً بوقت 1980
Gradle 9.8.01m42s3s مع FROM-CACHE3s، كل المهام UP-TO-DATEمتطابق حتى بين Containers جديدة
dotnet SDK 10.0.40144s منها 30s للاستعادةلم نقس6.6sمتطابق في نفس المسار فقط
Go 1.27.125s مع المكتبة القياسية0.5s0.5sمتطابق مع -trimpath

والأرقام من جهاز واحد في يوم واحد، وهي توضيحية، والدرس منها ليس أي أداة أسرع، وإنما أن البناء الأول يذهب أغلبه في التنزيل، لذلك ذاكرة الاعتماديات في CI تختصر أكبر جزء من الوقت.

التنظيف

docker rm -f lab-build-maven lab-build-gradle lab-build-dotnet lab-build-make
docker volume rm lab-build-m2 lab-build-gradle-home lab-build-nuget
docker image rm maven:3.10.0-eclipse-temurin-25 gradle:9.8.0-jdk25 mcr.microsoft.com/dotnet/sdk:10.0.401

واحذف ال Images فقط إذا لم تكن تستخدمها في مشاريع أخرى.

أي أداة تستخدم؟

في أغلب الحالات لا تختار أداة البناء، وإنما تأتي مع اللغة والفريق: Go وRust معهما أداتهما، و.NET معه dotnet وMSBuild، وJavaScript معه npm ومجمع مثل Vite. والاختيار الحقيقي في Java بين Maven وGradle، وفيما يلي نقاط القوة ونقاط الضعف لكل منهما:

الأداةنقاط القوةنقاط الضعف
Mavenشكل ثابت لكل المشاريع، أي مطور Java يفهم pom.xml فوراً، دعم واسع في كل أدوات CI والفحصXML طويل، البناء التدريجي محدود بالترجمة، والمنطق الخاص يحتاج إضافة Plugin
Gradleبناء تدريجي بالمحتوى، Build Cache محلي ومشترك، مرونة كاملة لأن الملف كودالمرونة نفسها تجعل كل مشروع مختلفاً، ومنحنى تعلم أعلى

والقاعدة: للمشاريع الصغيرة والمتوسطة وللفرق التي تريد شيئاً لا يحتاج تفكيراً Maven يكفي، وللمشاريع الكبيرة متعددة الوحدات Multi-module حيث يصبح وقت البناء مشكلة يومية فإن Gradle مع ال Build Cache يوفر وقتاً حقيقياً، وإذا كبر المشروع أكثر من ذلك ستسمع عن أدوات مثل Bazel المصممة للمستودعات الكبيرة جداً مع Cache مشترك.

أخطاء شائعة

  • «يعمل على جهازي»: نسخة JDK أو SDK على جهاز المطور تختلف عن CI، والحل نسخة محددة في Image البناء، وGradle Wrapper أو ملف global.json لتثبيت نسخة SDK في .NET.
  • وسم latest في Image البناء: يتغير دون أن تعرف، فيتغير الناتج أو يفشل البناء في يوم لم يتغير فيه الكود.
  • تجاوز الاختبارات للسرعة: -DskipTests في خط النشر يعني أن الاختبار الذي رأيناه يفشل كان سيمر إلى الإنتاج.
  • عدم حفظ ذاكرة الاعتماديات في CI: كل بناء ينزل مئات الملفات، ويصبح البناء معطلاً كلما تعطل المستودع الخارجي.
  • إعادة البناء لكل بيئة: ناتج staging ليس هو ناتج الإنتاج، فما اختبرته ليس ما نشرته.
  • ترك نسخ الإضافات Plugins دون تحديد في Maven: تتغير مع نسخة Maven نفسها.
  • الثقة بال Cache دائماً: مهمة خاصة لم تعرف كل مدخلاتها تعطي ناتجاً قديماً، وعند الشك ابن دون Cache.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • البناء تحويل الكود إلى ناتج Artifact عبر خطوات ثابتة تقريباً: حل الاعتماديات ثم الترجمة ثم الربط ثم الاختبار ثم التحزيم، والنشر خطوة منفصلة في CI.
  • Make الذي وصفه Feldman عام 1978 وضع القاعدة التي ما زالت تعمل: يعاد بناء الهدف إذا كان أقدم من أي ملف يعتمد عليه.
  • Maven يصف المشروع في pom.xml ويشغل دورة حياة ثابتة كل مرحلة فيها تشغل ما قبلها، وGradle يقارن هاش المدخلات فيتجاوز ما لم يتغير وينسخ من ال Build Cache، وdotnet يعمل فوق MSBuild الذي يقارن أوقات الملفات.
  • البناء القابل للتكرار يعني نفس الهاش لكل من يبني نفس الكود بنفس البيئة والتعليمات، وأعداؤه الوقت والمسار ونسخ الأدوات وترتيب الملفات، وMaven 3.10 وGradle 9 و.NET تعالج أغلبها افتراضياً.
  • في CI ابن داخل Container بنسخة محددة، واحفظ ذاكرة الاعتماديات، وشغل الاختبارات، وابن مرة واحدة وانقل نفس الناتج إلى كل البيئات.

وأقرب المقالات إلى هذا الموضوع على الموقع:

دليلGitea Actions: تشغيل CI/CD وبناء Docker Images على سيرفركاجعل سيرفرك يختبر الكود ويبني Docker Images تلقائياً مع كل push، دون أن يخرج كودك أو أسرارك إلى خدمة CI خارجية.دليلDevSecOps للمبتدئين: الأمن داخل خط CI/CD ومتى يعمل كل فحصما الذي يجده كل فحص أمني في خط CI/CD وما الذي يفوته، وكيف ترتب نتائجه حتى تصل إلى قرار بدل قائمة طويلة.دليلأمن سلسلة التوريد البرمجية Software Supply Chain للمبتدئينكل ما بين كود غيرك وسيرفرك قد يحمل كود المهاجم، وهنا تعرف التهديدات الحقيقية بحوادث موثقة والدفاع المناسب لكل حلقة.دليلSCA: كيف تجد الثغرات المعروفة في مكتبات مشروعككيف تجد أداة فحص الاعتماديات SCA المكتبات المصابة في مشروعك، ولماذا تختلف نتائج الأدوات، وكيف تبقي مكتباتك محدثة.دليلمن الفرع إلى الإنتاج: البيئات وGitOps للفرقكيف يختبر فريقك كل ميزة وحدها ويعرضها على العميل دون أن تصل ميزة غير جاهزة إلى الإنتاج.دليلأنماط البرمجة وأنظمة الأنواع: لماذا تختلف اللغات؟نحل مسألة واحدة بعدة أساليب برمجة، ونرى هل تكتشف اللغة أخطاءك قبل التشغيل أم بعده، وماذا يعني ذلك عند اختيار لغة لمشروعك.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجربة العملية على Docker Engine 29.8.0 مع Maven 3.10.0 وGradle 9.8.0 على Temurin JDK 25.0.4.1، و.NET SDK 10.0.401، وGo 1.27.1 وGNU Make 4.3 وgcc 12.2.0.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.