وہ تقسیم جو ہار گئی
ہماری Unity بلڈ پائپ لائن ایک ہی ci.yml میں 3,744 سطروں تک پہنچ گئی۔ ایک فائل ٹیسٹ
سویٹس، چار پلیٹ فارم بلڈز، آرٹیفیکٹ پبلشنگ، آرٹیفیکٹس کے مابین ڈیٹرمنزم کی تصدیق،
کارکردگی کے ریگریشن گیٹ، Discord اعلانات، اور تین Steam depot لینیں چلا رہی تھی۔ یہ
اسے توڑنے کی کہانی ہے — بشمول وہ متوازی تقسیم جس نے چیزیں سست کر دیں، اور یہ کہ
ہمارے پاس آخرکار چار الگ الگ workflow linters کیوں ہیں۔
یک سنگی ڈھانچے کی اصل قیمت
یہ فائل اسی طرح بڑھی جیسے یہ ہمیشہ بڑھتی ہیں۔ اگست کے آغاز میں 1,039 سطریں۔ 19 تاریخ تک 2,082۔ 22 کو 3,547، اور اسی دن 3,744 پر عروج۔
اصل مسئلہ حجم نہیں تھا۔ تکرار تھی:
- rclone کی تیاری — دس لفظ بہ لفظ نقول
- Unity کا mutex + لانچ + نتیجہ جانچنے والا بلاک — سات نقول
- R2 کا environment بلاک — چودہ نقول
اور وہ بہک بھی چکی تھیں۔ کچھ rclone نقول خاموشی سے tool cache پر واپس گر جاتی تھیں، کچھ انسٹال کا لاگ لکھتی تھیں، اور macOS والی نقول میں ایک آرکیٹیکچر سوئچ تھا جو Windows والی میں نہیں تھا۔ Unity کے سات بلڈ بلاکس میں سے صرف کچھ یہ جانچتے تھے کہ پلیئر واقعی بنا بھی یا نہیں — اور یہ اہم ہے، کیونکہ Unity کا صفر پر ختم ہو جانا مگر کچھ نہ بنانا اتنی بار ہوتا ہے جتنا کوئی چاہے گا نہیں۔
اصل محرک سلیقہ نہیں تھا۔ دوسری گیم تھی۔ لکھا ہوا مقصد یہ تھا: .github/ کاپی کیجیے،
مٹھی بھر ریپازٹری متغیرات مقرر کیجیے، اور اگلا پروجیکٹ کھڑا ہو جائے — YAML میں ایک
سطر بدلے بغیر۔ ہر ہارڈ کوڈ شدہ Steam app id، depot نمبر، builder namespace اور bucket
prefix اس مقصد کے راستے میں رکاوٹ تھا۔
پہلی لہر: وہ تقسیم جو ہار گئی
سویٹ ایک ہی job کے طور پر چلتا تھا — پہلے EditMode، پھر PlayMode، پھر بلڈز — کل 72 منٹ۔ ظاہری قدم یہ تھا کہ اسے تین متوازی jobs میں پھیلا دیا جائے۔
حساب درست لگ رہا تھا۔ فی job تیاری (workspace کی صفائی، vendor pack کی بوائی، پیکیج manifest، ایڈیٹر کی شناخت) 21 سیکنڈ ناپی گئی۔ اسے تین بار ادا کرنے کی قیمت تقریباً 42 اضافی سیکنڈ ہے، اور بدلے میں تنقیدی راستے سے تقریباً دس منٹ کم ہو جاتے ہیں۔
پھر ہم نے اسے ناپا:
| ڈیزائن | EditMode | PlayMode | بلڈز | کل وقت |
|---|---|---|---|---|
| یک سنگی (ایک job) | 10m14s | 15m31s | ~46m | 72m09s |
| تین طرفہ تقسیم | 24m25s | 18m27s | 69m23s | 69m |
| دو طرفہ (سویٹس ∥ بلڈز) | مجموعی 46m03s | ~55m |
تین طرفہ تقسیم نے تقریباً 4% دیا۔ دو سلاٹ پر واپس آنے نے 25% دیا۔
EditMode — یعنی CPU پر بوجھ ڈالنے والا سویٹ — سب سے زیادہ متاثر ہوا، اور تین طرفہ تقسیم کے تحت یک سنگی ڈھانچے کے مقابلے میں 2.4 گنا سست چلا۔
وجہ بعد میں دیکھنے پر شرمندگی کا باعث ہے اور صاف کہنے کے قابل: تینوں runner سلاٹ ایک ہی طبعی مشین پر انسٹینس ہیں۔ تیسرے بیک وقت چلتے Unity ایڈیٹر نے کوئی گنجائش شامل نہیں کی۔ اس نے اسی مشین کو مزید بانٹ دیا۔
جس حساب نے تین طرفہ تقسیم کو جواز دیا وہ درست تھا اور بےمعنی تھا۔ سوال کبھی تقسیم کی قیمت کا تھا ہی نہیں۔ سوال یہ تھا کہ مشین میں تقسیم ہونے کی گنجائش ہے بھی یا نہیں۔
واپس سمیٹنے سے حیرت انگیز مقدار میں وہ مشینری بھی حذف ہو گئی جو صرف اسی تقسیم کے لیے
موجود تھی: ایک پورا report job (دونوں نتیجہ XML واپس ایک ہی workspace میں ہیں، سو
انہیں جوڑنا دوبارہ ایک step بن گیا)، ان XMLs کی آرٹیفیکٹ منتقلی، اور فی سویٹ دو
خلاصہ steps۔ یہ پیمائش اب خود workflow کے سرِ فہرست تبصرے میں لکھی ہے، اُس اگلے شخص
کے لیے مستقل ممانعت کے طور پر جس کے ذہن میں یہی خیال آئے گا۔
اس واپسی میں صرف ایک چیز بچی: وہ composite action جو اسی تقسیم کے لیے نکالا گیا تھا — دہرائی گئی تیاری واپس نہیں آئی۔
دوسری لہر: تحلیل
تین دن بعد اصل تشکیلِ نو آئی:
.github/actionlint.yaml | 27 +
.github/workflows/_tests.yml | 419 +++++
.github/workflows/cd.yml | 2532 ++++++++++++++++++++++++++++++
.github/workflows/ci.yml | 3530 ++++--------------------------------------
4 files changed, 3319 insertions(+), 3189 deletions(-)
ci.yml 3,273 سطروں سے 425 پر آ گیا۔
زنجیر سے نہیں، محرک سے تقسیم
فطری خیال یہ ہے کہ CI کو CD سے workflow_run کے ذریعے جوڑ دیا جائے۔ ایسا نہ کیجیے۔
وہ محرک ڈیفالٹ برانچ والی نقل چلاتا ہے اور اسے چلانے والا ref کھو دیتا ہے، جس سے
"کسی فیچر برانچ سے دستی طور پر چلانے" کا ہر منظرنامہ ٹوٹ جاتا ہے۔
اس کے بجائے تقسیم محرک کے حساب سے ہے۔ ci.yml pull request پر چلتا ہے؛ cd.yml
dev/qa/main پر push اور دستی dispatch پر۔ ایک واقعے پر ٹھیک ایک چلتا ہے۔ دونوں
وہی دوبارہ قابلِ استعمال _tests.yml بلاتے ہیں، سو ایک push پر سویٹ اب بھی ٹھیک ایک
بار، اور سب سے پہلے چلتا ہے، اور تعیناتی کی لینیں اسی کے outputs پر انحصار کرتی ہیں۔
تین لینیں، ایک نفاذ
git dev -> Steam "development" خودکار
git qa -> Steam "qualityassurance" خودکار — qa میں مرج ہونا ہی وہ گیٹ ہے
git main -> ڈیفالٹ برانچ صرف اپلوڈ؛ لائیو کرنا انسان کا کام ہے
Steam کے برانچ نام git کے ناموں سے میل نہیں کھا سکتے: Steamworks 4 تا 32 حروف کی پابندی
لگاتا ہے، سو dev اور qa دونوں بنتے وقت مسترد ہو جاتے ہیں۔ ہمیں یہ اُس نقشے کو
جوڑنے کے بعد پتہ چلا جو کبھی کام کر ہی نہیں سکتا تھا۔
یہ لینیں محض ایک job کیوں نہیں ہو سکتی تھیں: Actions میں needs: ساکن ہے۔ ایک job
"جو جوڑا چلا ہو اس" کا انتظار نہیں کر سکتا۔ ایک واحد تعیناتی job کو پانچوں بالائی jobs
پر انحصار کرنا پڑتا، اور اوپر سے یہ طے کرنے کی شاخ در شاخ منطق کہ وہ کس لین میں ہے —
یعنی ٹھیک وہی شکل جو دیکھنے میں ٹھیک لگتی ہے اور پھر ناقابلِ مطالعہ انداز میں ٹوٹتی
ہے۔ چنانچہ 452 سطری تعیناتی job ایک 694 سطری دوبارہ قابلِ استعمال workflow بن گیا، اور
اسے بلانے والے چار پتلے کالر جو صرف پیرامیٹرز میں مختلف ہیں۔
پالیسی کے فرق inputs ہیں، کوڈ کی شاخیں نہیں۔ dev لین جان بوجھ کر ٹیسٹ سویٹ کے گیٹ سے
آزاد ہے — سویٹ کا سرخ ہونا آپ سے آرٹیفیکٹس نہ چھینے۔ prod لین گیٹ کے تابع ہے،
کیونکہ ایک خراب dev بلڈ کی قیمت دوبارہ تعیناتی ہے، جبکہ qa یا main پر ایک خراب
Steam بلڈ مستقل ہے۔
always() کبھی نہیں
ایک صفائی نے job گراف سے پانچ جگہوں پر always() ہٹا دیا۔ وجہ بہت مخصوص ہے:
always() تب بھی true دیتا ہے جب پورا run منسوخ ہو چکا ہو، اور CD کا concurrency
گروپ چلتے run کو منسوخ کرتا ہے۔ سو: dev کا push A دونوں بلڈز میں سبز ہو جاتا ہے، push
B اسے پیچھے چھوڑ کر run A منسوخ کر دیتا ہے، اور run A کی تعیناتی پھر بھی چل پڑتی ہے —
ایک متروک کمٹ Steam پر چڑھا کر لائیو کر دیتی ہے۔ Steam بلڈز حذف نہیں کیے جا سکتے۔
اس کی جگہ !cancelled() ہے۔
پانچ composite actions
ہر ایک اس لیے موجود ہے کہ تکرار پہلے ہی بہک چکی تھی:
unity-workspace(272 سطریں) — checkout سے لے کر ایڈیٹر چلانے تک کا سب کچھ۔ یہ جان بوجھ کر ریپو checkout نہیں کرتا، کیونکہ مقامی action خود workspace سے لوڈ ہوتا ہے۔unity/build-player(144 سطریں) — میزبان کے عالمی لاک کے تحت ایک Unity بیچ بلڈ، اور ایک دیانتدار نتیجہ: غیر صفر اخراج سرخ ہے، اور صفر اخراج جس نے کوئی پلیئر نہ بنایا ہو، اتنا ہی سرخ ہے۔ یہ خود ہمیشہ صفر پر ختم ہوتا ہے اور نتیجہ output سے دیتا ہے، تاکہ ٹوٹا ہوا Linux بلڈ Windows کی zip کی قیمت نہ بنے۔rclone/setup(134 سطریں) — rclone کو tool cache میں حل کرتا ہے، فی runner ایک بار۔ یہ جان بوجھ کر R2 کے credentials کو input کے طور پر نہیں لیتا: اس سے چھ سطریں بچانے کے لیے credentials ایک step سے پورے job تک پھیل جاتے۔git-bash(76 سطریں) — خود میزبان Windows runner پر PATH کاbashدراصل WSL کا لانچر ہے، جو Windows اسکرپٹ کا راستہ بگاڑ دیتا ہے۔unsparse-workspace(51 سطریں) — نیچے دیکھیے۔
دو پابندیوں نے ان سب کو شکل دی: composite actions کالر کے job اور workspace کے اندر
چلتے ہیں (جبکہ دوبارہ قابلِ استعمال workflows اپنے runner پر پورے jobs ہوتے ہیں)، اور
composite actions secrets سیاق پڑھ نہیں سکتے۔ دوسری بات نے ہمیں دو بار کاٹا۔
چار linters، چار لائیو ہو چکے نقص
ایک دستاویزی جملے نے ہر Unity job گرا دیا
unity-workspace نکالنے کے بعد پہلا ہی run: تینوں Unity jobs ایک منٹ کے اندر مر گئے،
Unrecognized named-value: 'secrets' کے ساتھ۔ مقام: action.yml کی سطر 19 — یعنی ایک
input کی تفصیل، جس میں لکھا تھا "کالر کو ${{ secrets.UTOOLS_PAT }} بھیجنا ہوگا"۔
GitHub تفصیل کے خانوں میں بھی template اظہاریے حل کرتا ہے، اور action کے پاس secrets
سیاق ہوتا ہی نہیں۔
پھر یہی قسم دوبارہ آئی، اس بار شیل تبصرے میں
تین دن بعد، ایک run: بلاک کے اندر وضاحتی تبصرے میں لکھا تھا "کالر
${{ vars.VENDOR_PACK_HOST_ROOT }} بھیجتا ہے"۔ شیل کا تبصرہ runner کے لیے تبصرہ نہیں
ہوتا — یہ اظہاریہ اُسی وقت جانچا جاتا ہے جب action لوڈ ہوتا ہے۔ اس نے مرج کے بعد
پہلے ہی CD run میں dev کی CD گرا دی۔
اسے کسی نے کیوں نہ پکڑا: actionlint action.yml فائلوں کو سرے سے نہیں جانچتا؛ ہمارا
syntax چیکر پارس کرنے سے پہلے ہر ${{ }} کو ننگے token سے بدل دیتا ہے، سو دیکھنے
کے وقت اظہاریہ موجود ہی نہیں ہوتا؛ اور ہمارا workflow linter صرف doc['jobs'] پر چلتا
تھا۔ تین گیٹ، تین مختلف اندھے دھبے، ایک ہی نقص۔
وہ اخراجی کوڈ جس نے PASSED چھاپا اور پھر ناکام ہو گیا
GitHub ہر shell: powershell step کے آخر میں exit $LASTEXITCODE جوڑ دیتا ہے۔ اور
PowerShell کے cmdlets $LASTEXITCODE کو ری سیٹ نہیں کرتے۔ چنانچہ ایک step جو کوئی
مقامی کمانڈ چلاتا ہے، اس کی ناکامی کو غیر مہلک قرار دیتا ہے، اور پھر cmdlets پر ختم
ہوتا ہے — وہ بہرحال ناکام ہو گا، اور اُسی کمانڈ کا اخراجی کوڈ اٹھائے ہوئے جسے اس نے
ابھی معاف کیا تھا۔
ایک اصل run لاگ سے:
tar entry for the player: -rwxr-xr-x 0 root root 4672 linux/CandyRush
Linux executable bit PASSED (0755 in the ustar stream).
##[error]Process completed with exit code 1.
بنیادی وجہ: tar.exe ... | Where-Object {...} | Select-Object -First 1 پہلا میچ ملتے ہی
پائپ لائن ختم کر دیتا ہے، tar.exe کی طرف جاتا پائپ توڑ دیتا ہے، اور $LASTEXITCODE
غیر صفر چھوڑ جاتا ہے۔ step نے اُس مقامی کمانڈ کا اخراجی کوڈ ورثے میں لیا جسے اس نے
جان بوجھ کر کاٹا تھا — حالانکہ اس کی اپنی منطق پہلے ہی کامیاب ہو چکی تھی اور یہ کہہ
بھی چکی تھی۔
درستی سے کہنا ضروری ہے، کیونکہ یہ بات بدیہی کے خلاف ہے: یہ لاگ میں PASSED کے ساتھ
جھوٹا سرخ ہے، جھوٹا سبز نہیں۔ اس کا آئینہ دار خطرہ بھی لائیو جا چکا ہے — ایک ایسا
اپلوڈ جسے صرف تنبیہ کرنی تھی، وہ پورے builds job کو مار دیتا، اور ان میں سے ایک بار تو
ok=true لکھنے کے بعد، یعنی فیصلہ اور job کا نتیجہ ایک دوسرے کی نفی کرتے۔
یہ قسم چار بار لائیو جا چکی تھی۔ ہر بار کسی انسان نے diff پڑھ کر پکڑی، اور یہ کوئی طریقۂ کار نہیں ہے۔ اب یہ ایک linter اصول ہے۔
ہر run: بلاک کو اصل کوڈ کی طرح پارس کرنا
ایک مشینی تبدیلی نے ہارڈ کوڈ فہرست کو متغیر پر مبنی لوپ سے بدلا، مگر پرانے بلاک کا
اختتامی } وہیں چھوڑ دیا۔ YAML جائز۔ actionlint: صفر۔ ہمارا workflow linter: صفر۔
runner پر ایک یقینی پارس خطا، اور وہ بھی job کے قطار میں لگنے کے کئی منٹ بعد۔
سو check-step-syntax.py ہر run: بلاک نکالتا ہے، اظہاریوں کو ننگے tokens سے بدلتا
ہے (یہی وہ چیز ہے جو شیل کو واقعی ملتی ہے)، اور اسے اس کی زبان کے اصل پارسر کے حوالے
کرتا ہے — PowerShell کا اپنا AST پارسر، اور bash -n۔
دو تفصیلات چرانے کے قابل ہیں۔ پہلی: یہ شیل کو اخذ کرتا ہے، اعلان پر بھروسہ نہیں
کرتا، کیونکہ shell: bash بےاثر نہیں ہے: ضمنی ڈیفالٹ bash -e ہے، جبکہ صراحتاً لکھا
ہوا shell: bash دراصل bash --noprofile --norc -eo pipefail ہے۔ اسے لکھ دینے سے
موجودہ پائپ والے steps پر pipefail نیا نافذ ہو جاتا اور ایک کامیاب job سرخ ہو سکتا
تھا۔ کوئی syntax گیٹ خود کو نصب کرنے کے لیے رن ٹائم رویہ نہ بدلے۔
دوسری: پہلی نسخے میں فی step ایک انٹرپریٹر چلتا تھا — 77 PowerShell آغاز، تقریباً 9 سیکنڈ خالص fork اوور ہیڈ، تاکہ شاید 200 ملی سیکنڈ کی پارسنگ ہو۔ ایک ہی کال میں سمیٹنے سے یہ 9.7 سیکنڈ سے 0.95 سیکنڈ پر آ گیا۔
job گراف کا سراغ
چوتھا گیٹ ہر job کے if: کو گیارہ حقیقی محرک شکلوں کے خلاف جانچتا ہے اور اگر کوئی job
ان سب میں ناقابلِ رسائی ہو تو ناکام ہو جاتا ہے۔ بیس jobs ضرب گیارہ شکلیں یعنی 220 جواب؛
کسی ایک شرط کی تبدیلی ان میں سے کچھ کو خاموشی سے ہلا دیتی ہے اور یہ سب diff میں نظر
نہیں آتا۔
اس نے فوراً ایک ایسا builds job ڈھونڈ نکالا جو چلا، پوری تیاری مکمل کی، اور پھر ہر
بلڈ step چھوڑ دیا — اور یہ عین اُس dispatch پر ہوا جو ایک مستقل Steam بلڈ بھیجتا ہے۔
وجہ: چار تہہ در تہہ نفیاں، جن میں سے ہر ایک اُس وقت شامل ہوئی جب کوئی نیا dispatch
لیور آیا، اور جنہیں پڑھ کر کوئی حساب نہیں لگا سکتا تھا۔
وہ گیٹ جو پاس ہوتے رہے مگر جانچتے کچھ نہیں تھے
بہترین سبق خود linters کی ایک ناکامی سے آیا۔
ایک run could not read ".github/workflows/*.yml" کے ساتھ ناکام ہوا — وہ لفظی glob
actionlint تک اس لیے پہنچا کہ bash کے پاس پھیلانے کو کچھ تھا ہی نہیں۔ workspace کو
کاٹ دیا گیا تھا: actions/checkout sparse-checkout آن کرتا ہے اور اسے کبھی واپس بند
نہیں کرتا، اور ہمارے utility runner کا workspace مشترک اور پائیدار ہے۔ ایک job برسوں
سے sparse checkout کر رہا تھا — اور یہ درست تھا، جب تک وہ ایک عارضی میزبان runner
پر چلتا تھا۔ اسے خود میزبان مشین پر منتقل کرنے سے مفروضہ بدل گیا، مگر checkout نہیں
بدلا۔
مگر دوسرا حصہ زیادہ اہم ہے۔ اُسی کٹے ہوئے run میں، ہمارے تین خود ساختہ گیٹوں میں سے دو صفر فائلیں پاتے، صفر بار لوپ کرتے، اور کچھ بھی جانچے بغیر کامیابی کی اطلاع دے دیتے۔ صرف تیسرے کے پاس input کا محافظ تھا، اور یہی واحد وجہ ہے کہ یہ ناکامی ایک سرخ step کے طور پر سامنے آئی، نہ کہ تین خاموشی سے سبز ہوتے گیٹوں کے طور پر۔
اب یہ سب input غائب ہونے پر ناکام ہوتے ہیں، اور ایک خالی ڈائریکٹری سے اس کی الٹی جانچ بھی کی گئی ہے۔ اور چونکہ یہ حل اُس workspace کو نہیں بچا سکتا تھا جو پہلے ہی آلودہ ہو چکا ہو، اب ایک چھوٹا action بھی ہے جو sparse موڈ بند کرتا ہے اور پھر تصدیق کرتا ہے کہ درخت مکمل ہے۔
جو بات عام کی جا سکتی ہے
مشین ناپیے، حساب نہیں۔ تین طرفہ تقسیم کو تیاری کے وقت سے متعلق درست حساب اور ہارڈویئر سے متعلق غلط مفروضوں نے جواز دیا تھا۔ ایک ہی مشین پر تین runner سلاٹ تین مشینیں نہیں ہیں۔
وہ گیٹ جو "یاد رکھنے" پر منحصر ہو، گیٹ نہیں ہے۔ چار الگ الگ نقص، ہر ایک کسی انسان نے diff پڑھ کر پکڑا۔ یہ چلتا رہتا ہے، جب تک نہیں چلتا۔ اب ہر ایک ایک ایسا چیک ہے جو غیر صفر پر ختم ہوتا ہے۔
جو چیک کچھ نہ پائے، اسے ثابت کرنا ہوگا کہ اس نے دیکھا تھا۔ ایک linter جو خاموشی سے ایک خالی مجموعے کی تصدیق کرے، بغیر linter کے بھی بدتر ہے، کیونکہ وہ ایک ایسا سبز نشان دیتا ہے جس کا کوئی مطلب نہیں۔
تکرار کی قیمت بہاؤ ہے، سطریں نہیں۔ rclone کی دس نقول اس لیے مسئلہ نہیں تھیں کہ وہ لمبی تھیں۔ وہ اس لیے مسئلہ تھیں کہ ان میں سے صرف کچھ اب بھی درست تھیں — اور Unity کے بلڈ بلاک میں، صرف کچھ یہ جانچتی تھیں کہ بلڈ نے واقعی کچھ بنایا بھی یا نہیں۔
اب یہ پائپ لائن ایک 3,744 سطری فائل کے بجائے چھ فائلوں میں 5,588 سطریں workflow ہے، اور ساتھ 677 سطریں composite actions اور 2,741 سطریں اسکرپٹس۔ یہ چھوٹا ہونا نہیں ہے۔ یہ الگ الگ کیا جا سکنے والا، پیرامیٹر پذیر، اور گیٹ کے تابع ہونا ہے — اور اگلی گیم کو اسے YAML بدلے بغیر، صرف ریپازٹری متغیرات مقرر کر کے استعمال کر لینا چاہیے۔ مقصد یہی تھا۔