17.2 Build System และ CI/CD

เฟส 17 · วิศวกรรมซอฟต์แวร์และ Production · เวลาเรียน: 15–30 h

โค้ดกลายเป็น build ยังไง — การ compile และ link, CMake สำหรับ C++ และ continuous integration ที่เทสต์และ build อัตโนมัติ

ตลอดมา คำถามว่า "เกมทำงานไหม" ส่วนใหญ่หมายถึง "มันทำงานไหมตอนที่เรากด play บนคอมพิวเตอร์ของเราเอง" คำถามแบบนี้ใช้ไม่ได้อีกต่อไปทันทีที่มีคนมากกว่าหนึ่งคนแตะโปรเจกต์เดียวกัน เกมจริง ๆ ถูกสร้างโดยทีม ถูกทดสอบโดยคนที่ไม่ใช่โปรแกรมเมอร์ที่เขียนโค้ดตรงนั้น และสุดท้ายก็ต้องถูกติดตั้งบนฮาร์ดแวร์ที่ไม่มีใครในทีมเป็นเจ้าของ บทนี้พูดถึงกลไกที่สตูดิโอสร้างขึ้นมา เพื่อให้การเปลี่ยนแปลงที่ใครสักคนทำบนโน้ตบุ๊กของตัวเองสามารถพิสูจน์ได้ว่าใช้งานได้จริงในที่อื่นด้วย โดยอัตโนมัติ ภายในไม่กี่นาที และจบลงที่โทรศัพท์ของ tester ได้โดยไม่มีใครต้องก็อปปี้ไฟล์ด้วยมือ ไม่มีอะไรในนี้ที่แปลกใหม่เลย — มันคือโครงสร้างพื้นฐานธรรมดา ๆ ที่ทุกสตูดิโอที่เคยชิปอะไรออกไปใช้กัน ตั้งแต่ทีม indie สองคนไปจนถึงโปรดักชันระดับ HoYoverse

1. "It Works on My Machine": ปัญหาที่มีจริงและแพงจริง

ลองนึกภาพโปรแกรมเมอร์สามคนในทีมเล็ก ๆ ทีมเดียวกัน Alex ลง Unity 2022.3.10f1 พร้อม Android build module และ Android SDK ที่ใหม่กว่าคนอื่นในทีมนิดหน่อย เพราะ Alex เพิ่งอัปเดตมันไปเมื่อสัปดาห์ก่อนด้วยเหตุผลอื่นที่ไม่เกี่ยวกัน Priya ใช้ Unity 2022.3.4f1 ซึ่งเป็นเวอร์ชัน patch เก่ากว่า เพราะเธอไม่ได้อัปเดตมาเป็นเดือนแล้ว Sam ใช้ Unity เวอร์ชันเดียวกับ Alex แต่มีไฟล์อีกสามไฟล์วางอยู่ในโฟลเดอร์โปรเจกต์ที่ไม่เคย commit เข้า git เลย — เป็นโค้ดทดลองที่เหลือจากฟีเจอร์ที่ถูกพับไปแล้ว ยังคงอยู่บนดิสก์ และถูก Unity Editor หยิบมาใช้เงียบ ๆ ทั้งที่ไม่มีใครในทีมมีไฟล์พวกนี้เลย

ทีนี้ Alex เขียนฟีเจอร์ใหม่ กด play มันทำงาน แล้วก็ push การเปลี่ยนแปลงนี้ออกไป บนเครื่องของ Alex เรื่องนี้เป็นจริงทั้งหมด — ฟีเจอร์ทำงานได้จริง แต่คำว่า "ทำงาน" ตรงนี้แอบขึ้นอยู่กับ Android SDK เวอร์ชันใหม่กว่าที่ Alex บังเอิญลงไว้ พอ Priya pull การเปลี่ยนแปลงมาในเช้าวันถัดไปแล้ว build ก็เจอ compile error ที่อ้างถึง API ที่ SDK เวอร์ชันเก่าของเธอไม่มี ส่วน Sam pull การเปลี่ยนแปลงเดียวกันมา แล้ว build ผ่านฉลุย — แต่ผ่านได้เพราะไฟล์ที่หลงเหลืออยู่สามไฟล์นั้นมีไฟล์หนึ่งที่บังเอิญ define class ที่โค้ดใหม่แอบพึ่งพาอยู่ ซึ่งเป็น class ที่ไม่มีอยู่ใน git เลยแม้แต่นิดเดียว

ทั้งหมดนี้คือปัญหาแบบเดียวกันที่มักถูกสรุปสั้น ๆ ว่า "it works on my machine" มันไม่ใช่มุกตลกเรื่องข้ออ้างขี้เกียจ — แต่เป็นคำอธิบายที่ตรงเป๊ะของ build ที่ผ่านได้เพราะบางอย่างที่เป็นจริงเฉพาะบนคอมพิวเตอร์เครื่องหนึ่งเท่านั้น ไม่ใช่เป็นจริงสำหรับตัว codebase เอง: tool เวอร์ชันที่ลงไว้, ไฟล์หลงเหลือที่ไม่ได้ commit, environment variable ที่ตั้งไว้เมื่อหลายเดือนก่อนแล้วลืมไป, package ที่แคชไว้ตั้งแต่ก่อนมีการเปลี่ยน dependency สิ่งเหล่านี้ไม่มีอันไหนอยู่ใน git เลย และทุกอันสามารถทำให้ build ผ่านสำหรับคนหนึ่งแต่ล้มเหลวสำหรับคนอื่นทั้งทีม

ต้นทุนตรงนี้ไม่ใช่แค่สมมติฐาน build ที่พังแล้วไม่มีใครสังเกตเห็นอยู่หลายวันจะไม่ใช่ปัญหาเล็ก ๆ อีกต่อไป — เพราะจะมีงานอีกเยอะถูกสร้างทับลงไปบนโค้ดที่พังอยู่ ก่อนที่จะมีใครรู้ตัวด้วยซ้ำว่ามีอะไรผิดปกติ

Day 1: commit a1b2c3d lands. It quietly breaks the build. Nobody does a completely fresh build today, so nobody notices yet. Day 2: 6 more commits land on top of a1b2c3d. Day 3: 14 more commits land on top of those. Day 4: a tester finally tries a clean install for a demo. Crash on launch. Question: which ONE of the 21 commits since a1b2c3d actually caused this? Answer: nobody knows yet. Someone now has to check, commit by commit, on the day before the demo.

นั่นคือเวลาหลายชั่วโมงของโปรแกรมเมอร์ที่ต้องเสียไปกับการตามหาความผิดพลาด ซึ่งถ้าถูกจับได้ตั้งแต่วันที่มันเกิดขึ้น ก็คงใช้เวลาแก้แค่ไม่กี่นาที — เพราะคนที่เขียนโค้ดนั้นยังจำได้แม่นว่าเพิ่งเปลี่ยนอะไรไป ที่เหลือของบทนี้จะพูดถึงการปิดช่องว่างระหว่าง "ความผิดพลาดเกิดขึ้น" กับ "ความผิดพลาดถูกจับได้"

2. ทางแก้: Build Server เฉพาะทาง

แนวคิดหลักนั้นเรียบง่ายจนแทบจะขายหน้า: เลิกเชื่อเครื่องของนักพัฒนาคนใดคนหนึ่งในการตอบคำถามว่า "โปรเจกต์นี้ build ได้จริงไหม" แล้วใช้เครื่องแยกต่างหากอีกเครื่องหนึ่งแทน — เรียกว่า build server (หรือเรียกว่า CI server หรือ CI runner ก็ได้ โดย CI ย่อมาจาก continuous integration ซึ่งจะพูดถึงในหัวข้อถัดไป) — ซึ่งมีหน้าที่เดียวคือ checkout โปรเจกต์จาก git มาแบบสด ๆ ใหม่เอี่ยมทั้งหมด แล้วลอง build ดู โดยไม่มีอะไรหลงเหลือจากการลองครั้งก่อนหน้าเลย

DEVELOPER LAPTOPS (each one quietly different) Alex: Unity 2022.3.10f1, newer Android SDK, no stray files Priya: Unity 2022.3.4f1, older Android SDK Sam: Unity 2022.3.10f1, newer Android SDK, 3 uncommitted files "it works on my machine" could mean any one of these setups BUILD SERVER (one machine, identical every single time) starts from a fresh virtual machine or a clean workspace clones the repository from git, from nothing installs exactly the Unity version the project specifies nothing exists on it that is not tracked in git if the build succeeds here, it succeeds for a reason everyone can trust -- not because of one person's leftover files

build server ถูกออกแบบให้น่าเบื่อโดยตั้งใจ มันไม่มี tool ที่ใครชอบเป็นการส่วนตัว ไม่มีโค้ดทดลองที่เขียนค้างไว้ ไม่มี environment variable ที่ลืมตั้งไว้ ทุกครั้งที่มันรัน มันจะเริ่มจาก state ที่สะอาดเหมือนเดิมทุกครั้ง และเห็นแค่สิ่งที่ถูก commit เข้า repository จริง ๆ เท่านั้น คุณสมบัติข้อเดียวนี้แหละที่ทำให้คำตอบของมันน่าเชื่อถือ: ถ้า build server บอกว่าโปรเจกต์ build ผ่าน นั่นแปลว่ามันเป็นจริงสำหรับใครก็ตามที่ checkout commit เดียวกัน ไม่ใช่แค่บังเอิญเป็นจริงสำหรับโน้ตบุ๊กเครื่องเดียว

Tip คำว่า "Clean" คือหัวใจทั้งหมด build server ที่ถูกใช้ซ้ำระหว่างการรันโดยไม่ล้างไฟล์เก่าออก จะค่อย ๆ สร้างปัญหา "it works on my machine" เวอร์ชันของตัวเองขึ้นมาช้า ๆ — ไฟล์หลงเหลือจากการรันครั้งที่ 40 อาจทำให้การรันครั้งที่ 41 ผ่านไปเงียบ ๆ ทั้งที่ถ้า checkout ใหม่แบบสด ๆ จริง ๆ จะต้องพังแน่นอน CI provider สมัยใหม่แก้ปัญหานี้ด้วยการแจกเครื่องใหม่แกะกล่องแบบใช้แล้วทิ้งให้ทุกการรัน แต่ในจุดที่วิธีนั้นช้าเกินไป (หัวข้อ 7 จะอธิบายว่าทำไม) ทีมงานก็จะ cache บางโฟลเดอร์ไว้อย่างตั้งใจ ไม่ใช่เกิดขึ้นโดยบังเอิญ

build server เพียงลำพังก็แค่เครื่องที่ build โปรเจกต์ได้หนึ่งครั้งตามต้องการ สี่หัวข้อถัดไปจะพูดถึงว่าอะไรเกิดขึ้นจริง ๆ ระหว่างการ build นั้น และตัว trigger ที่ทำให้มันรันถูกทำให้อัตโนมัติได้ยังไง จนไม่มีใครต้องมานั่งจำว่าต้องสั่งรันเอง

3. กายวิภาคของ Build Pipeline

build pipeline คือลำดับขั้นตอนที่ build server รันเรียงกัน ตั้งแต่ "นี่คือ commit หนึ่งอัน" ไปจนถึง "นี่คือ build ที่เสร็จสมบูรณ์ ติดตั้งได้แล้ว" pipeline จริง ๆ ของแต่ละสตูดิโอจะแตกต่างกันไปบ้าง แต่เกือบทั้งหมดมีรูปร่างแบบเดียวกันนี้:

PUSH TO GIT | v +----------------------------+ | 1 pull latest code | +----------------------------+ | v +----------------------------+ | 2 restore packages / deps | +----------------------------+ | v +----------------------------+ | 3 compile | +----------------------------+ | v +----------------------------+ | 4 run tests | +----------------------------+ | v +----------------------------+ | 5 build the player | | (per target platform) | +----------------------------+ | v +----------------------------+ | 6 sign the build | +----------------------------+ | v +----------------------------+ | 7 upload it somewhere | | testers can install it | +----------------------------+

ไล่ดูแต่ละขั้นตอน:

รายการนี้ยังไม่มีอะไรเจาะจงกับ Unity เลย — รูปร่างเดียวกันนี้ใช้ได้กับ mobile app, เว็บไซต์ หรือ desktop tool ก็ได้ หัวข้อ 6 ถึง 8 จะทำให้หลายขั้นตอนในนี้เป็นรูปธรรมเจาะจงกับโปรเจกต์ Unity โดยเฉพาะ

4. Continuous Integration: จับ Commit ที่พังได้ภายในไม่กี่นาที

continuous integration (มักย่อว่า CI) คือแนวปฏิบัติที่รัน build pipeline จากหัวข้อ 3 โดยอัตโนมัติ ทุกครั้งที่มีคน push commit ออกมา — ไม่ใช่สัปดาห์ละครั้งตอนที่มีใครนึกขึ้นได้ ไม่ใช่แค่ก่อนปล่อยเวอร์ชันเท่านั้น แต่ทุกครั้งที่ push โดยไม่ต้องมีมนุษย์มา trigger เอง ชื่อของมันหมายถึงการ integrate (merge) การเปลี่ยนแปลงของทุกคนเข้าด้วยกันตลอดเวลา แล้วตรวจสอบทุกครั้งว่าผลลัพธ์ยังใช้งานได้อยู่

คุณค่าของ CI แทบทั้งหมดอยู่ที่เรื่องจังหวะเวลา ลองเทียบดูว่า build ที่พังจากหัวข้อ 1 จะเป็นยังไงถ้าไม่มี CI กับถ้ามี CI:

WITHOUT CI push commit -> ... nothing checks it automatically ... -> bug discovered 3 days and 21 commits later -> expensive bisect through 21 commits to find the cause WITH CI push commit -> a build+test job starts automatically, in seconds -> pass/fail result posted back in a few minutes -> if it failed, the author still remembers exactly what they just wrote -- the fix takes minutes

สิ่งนี้มักถูกเรียกว่า fast feedback loop: ยิ่งเวลาระหว่างตอนที่เกิดความผิดพลาดกับตอนที่รู้ตัวว่ามันเกิดขึ้นสั้นเท่าไหร่ ความผิดพลาดนั้นก็ยิ่งแก้ง่ายและถูกเท่านั้น เพราะบริบทยังสดใหม่อยู่ในหัวของคนที่ทำมันขึ้นมา จุดประสงค์ทั้งหมดของระบบ CI คือทำให้ loop นั้นสั้นที่สุดเท่าที่จะทำได้ — หัวข้อ 5 จะอธิบายว่าทำไม "สั้นที่สุดเท่าที่จะทำได้" ถึงไม่เหมือนกับ "ทันทีทันใด" สำหรับการเช็คทุกแบบ

นี่คือตัวอย่าง GitHub Actions workflow ที่เรียบง่ายจริง ๆ — ไฟล์ configuration ที่เขียนด้วย YAML ซึ่งบอก build server ของ GitHub เองว่าต้องรันอะไรและเมื่อไหร่ ตัวอย่างนี้ใช้โปรเจกต์ C# ธรรมดาที่ build ด้วยเครื่องมือ command-line ชื่อ dotnet ตั้งใจทำให้เรียบง่ายเพื่อให้เห็นแนวคิดหลักของ CI ชัด ๆ หัวข้อ 6 จะขยายแนวคิดเดียวกันนี้ไปสู่โปรเจกต์ Unity เต็มรูปแบบ

name: CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the repository
        uses: actions/checkout@v4

      - name: Restore packages
        run: dotnet restore

      - name: Compile
        run: dotnet build --no-restore

      - name: Run tests
        run: dotnet test --no-build

ไล่อ่านทีละบรรทัด:

ถ้ามีขั้นตอนไหนจบด้วยความล้มเหลว (compile error, test ที่ fail) GitHub Actions จะหยุด job ตรงนั้นทันที ทำเครื่องหมายเป็นสีแดง และ — ขึ้นอยู่กับว่าทีมตั้งค่าไว้ยังไง — อาจบล็อกไม่ให้การเปลี่ยนแปลงนั้นถูก merge จนกว่าจะแก้เสร็จ นี่คือกลไกทั้งหมด: ไม่มีขั้นตอนไหนในนี้ที่ซับซ้อนด้วยตัวเอง คุณค่าทั้งหมดมาจากการที่มันรันอัตโนมัติ ทุกครั้งที่ push โดยไม่ต้องมีใครมาขอให้รันเลย

ข้อผิดพลาดที่พบบ่อย มองผลลัพธ์ CI สีแดงเป็นแค่ตัวเลือก หรือปล่อยให้ workflow พังค้างไว้ "ก่อนแป๊บนึง" เพราะการแก้รู้สึกไม่สะดวก พอทีมเริ่มชินกับการเมิน build สีแดง CI ก็จะหยุดทำหน้าที่ของมันไปเลย — ความล้มเหลวใหม่ ๆ ที่สำคัญจริง ๆ จะหายไปในความยุ่งเหยิงของสถานะสีแดงที่ทุกคนเรียนรู้ที่จะกดผ่านไปเฉย ๆ build สีแดงควรเกิดขึ้นน้อย และควรค่าแก่การแก้ทันทีเสมอ ไม่งั้นก็ไม่ควรมี check แบบนี้อยู่เลยด้วยซ้ำ

5. Nightly Build: ทำไมโปรเจกต์ใหญ่ ๆ ถึงรัน Job ยาว ๆ ตอนกลางคืน

ตัวอย่างในหัวข้อ 4 รันเสร็จในเวลาไม่ถึงนาทีด้วยซ้ำ แต่ pipeline เต็มรูปแบบของโปรเจกต์เกมจริง ๆ ไม่ได้เป็นแบบนั้น การ build player จริง ๆ สำหรับทุกแพลตฟอร์มที่สตูดิโอชิป, การรัน test suite เต็มชุดแทนที่จะเป็นแค่ subset สั้น ๆ, และการรันเช็คที่ใช้เวลานานอย่าง memory-leak soak test (ปล่อยให้เกมรันค้างไว้หลายชั่วโมงเพื่อดูว่าการใช้หน่วยความจำเพิ่มขึ้นเรื่อย ๆ ไหม) หรือ performance benchmark ก็สามารถกินเวลาเป็นชั่วโมงหรือมากกว่านั้นได้ง่าย ๆ บางทีก็หลายชั่วโมงเลย

การรันทั้งหมดนั้นทุกครั้งที่ push ในทางเทคนิคก็ยังนับเป็น "continuous integration" อยู่ดี แต่มันจะทำลายสิ่งที่ทำให้ CI มีคุณค่าไปเลย นั่นคือ fast feedback loop จากหัวข้อ 4 ไม่มีใครอยากรอสองชั่วโมงครึ่งเพื่อจะรู้ว่าการแก้บั๊กบรรทัดเดียวที่เพิ่ง push ไป compile ผ่านไหม ดังนั้นสตูดิโอส่วนใหญ่จะแบ่ง pipeline ออกเป็นสองระดับ แทนที่จะรันทุกอย่างทุกครั้ง

EVERY PUSH (fast lane) compile + unit tests + maybe one representative platform a few minutes, blocks nobody, runs dozens of times a day ONCE A NIGHT, e.g. 02:00 (slow lane) compile + the FULL test suite + EVERY platform (iOS, Android, Windows, PS5, Xbox, Switch) + a multi-hour soak test + full performance benchmarks a couple of hours, nobody is sitting there waiting on it -- results are simply ready by the time the team arrives in the morning

nightly build ก็คือระดับที่สองนี้เอง: job ที่ถูกตั้งเวลาให้รัน pipeline เวอร์ชันที่แพงและละเอียดในเวลาที่กำหนดไว้แน่นอน มักจะเป็นตอนกลางคืนตอนที่ไม่มีใครรอผลอยู่ และ build server ก็ว่างอยู่แล้วด้วย ใน GitHub Actions ใช้ trigger แบบ schedule ร่วมกับ cron expression (syntax แบบกระชับสำหรับ "รันตอนเวลานี้ ตามตารางนี้" ที่ใช้กันในเครื่องมือจัดตารางเวลาหลายตัว ไม่ใช่แค่ CI):

on:
  schedule:
    - cron: '0 2 * * *'

cron expression มีห้าฟิลด์: minute, hour, day-of-month, month, day-of-week แต่ละฟิลด์เป็นตัวเลขเจาะจง หรือเป็น * ที่แปลว่า "ค่าไหนก็ได้" 0 2 * * * อ่านว่า "ที่นาทีที่ 0 ของชั่วโมงที่ 2 วันไหนของเดือนไหนก็ได้ วันไหนของสัปดาห์ก็ได้" — พูดง่าย ๆ คือ 02:00 ทุกวัน ต้องระวังด้วยว่า schedule ของ GitHub Actions รันตามเวลา UTC ซึ่งควรเช็คให้ดีเทียบกับเวลาทำงานจริงของทีม

สองระดับนี้ทำงานร่วมกัน ไม่ได้ขัดกัน: job ที่ trigger จาก push แบบเร็วจะจับความผิดพลาดทั่วไปส่วนใหญ่ได้ภายในไม่กี่นาที ส่วน job ตอนกลางคืนจะจับปัญหากลุ่มเล็กกว่าที่โผล่มาเฉพาะตอนรันเต็ม platform matrix, test suite เต็มชุด หรือ soak test ยาว ๆ เท่านั้น — ปัญหาพวกนี้เป็นเรื่องจริง แต่ไม่คุ้มที่จะให้นักพัฒนาทุกคนรอทุกครั้งที่ push

6. Unity Headless Batch-Mode Build

build server ตามที่พูดไว้ในหัวข้อ 2 ไม่มีจอต่ออยู่และไม่มีใครนั่งอยู่หน้าเครื่อง นี่เป็นปัญหาสำหรับ Unity Editor โดยปกติ เพราะมันมักจะคาดหวังว่าจะเปิดหน้าต่างแบบ graphical ขึ้นมา Unity แก้ปัญหานี้ด้วย batch mode: วิธีรัน Editor ทั้งหมดจาก command line โดยไม่มีหน้าต่างเลย เหมาะกับเครื่องที่ไม่มีใครมองอยู่ — มักเรียกกันว่าการรันแบบ headless

logic ของการ build จริง ๆ อยู่ใน C# script ธรรมดาที่วางไว้ในโฟลเดอร์ Editor ของโปรเจกต์ โดยใช้ namespace UnityEditor ของ Unity ซึ่งมีให้ใช้แค่ในตัว Editor เท่านั้น (ไม่มีใน build ที่ชิปออกไปจริง) build script ทั่วไปหน้าตาจะประมาณนี้:

using UnityEditor;
using UnityEditor.Build.Reporting;
using UnityEngine;

public static class BuildScript
{
    // A human can also trigger this from the Editor menu -- useful
    // for testing the exact same build code CI will run.
    [MenuItem("Build/Build Android (CI)")]
    public static void BuildAndroidFromMenu()
    {
        BuildAndroid();
    }

    // CI calls this headlessly with:
    // -executeMethod BuildScript.BuildAndroid
    public static void BuildAndroid()
    {
        string[] scenes =
        {
            "Assets/Scenes/MainMenu.unity",
            "Assets/Scenes/Gameplay.unity"
        };

        BuildPlayerOptions options = new BuildPlayerOptions
        {
            scenes = scenes,
            locationPathName = "Builds/Android/game.apk",
            target = BuildTarget.Android,
            options = BuildOptions.None
        };

        BuildReport report = BuildPipeline.BuildPlayer(options);

        if (report.summary.result != BuildResult.Succeeded)
        {
            Debug.LogError("Build failed: " + report.summary.result);
            EditorApplication.Exit(1); // non-zero exit code tells CI this job failed
        }
    }
}

BuildPipeline.BuildPlayer คือฟังก์ชันตัวเดียวกับที่ Editor เรียกใช้ตอนที่มนุษย์กด "Build" ในหน้าต่าง Build Settings — script นี้แค่เรียกมันตรง ๆ จากโค้ดแทน โดยใช้รายการ scene และ setting ที่ตายตัว แทนที่จะเป็นอะไรก็ตามที่บังเอิญถูกตั้งค่าไว้ใน UI ของ Editor ตอนนั้น เรื่องนี้สำคัญ: build ที่ trigger จากโค้ดสามารถทำซ้ำได้เหมือนเดิมทุกครั้ง เพราะขับเคลื่อนด้วยสิ่งที่เขียนและ commit ไว้ตรงนี้ทั้งหมด ไม่ใช่ขึ้นอยู่กับว่าหน้าต่าง Editor ของใครคนหนึ่งบังเอิญติ๊กอะไรไว้ตอนนั้น

คำสั่ง command line ที่รันสิ่งนี้จริง ๆ บน build server หน้าตาจะประมาณนี้:

"/Applications/Unity/Hub/Editor/2022.3.10f1/Unity.app/Contents/MacOS/Unity" \
  -batchmode \
  -quit \
  -nographics \
  -projectPath . \
  -executeMethod BuildScript.BuildAndroid \
  -logFile build.log
CI runner (no monitor, no GPU display, nobody watching) | v Unity -batchmode -quit -nographics -executeMethod BuildScript.BuildAndroid | v Unity Editor loads headlessly, then runs BuildScript.BuildAndroid() | v BuildPipeline.BuildPlayer(...) writes game.apk to disk | v Unity exits because of -quit. CI reads the process exit code.

แต่ละ flag มีความหมาย:

EditorApplication.Exit(1) ใน script ด้านบนคือสิ่งที่เชื่อมความล้มเหลวระดับ Unity กลับไปหา CI จริง ๆ: exit code ปกติของ process ที่เป็น 0 หมายถึงสำเร็จ และ code ที่ไม่ใช่ 0 หมายถึงล้มเหลว ถ้าไม่มีการเรียกแบบนี้ตรง ๆ Unity อาจจบการทำงานเงียบ ๆ พร้อม build ที่ fail แต่ยังคง exit ด้วย code 0 อยู่ดี แล้ว CI pipeline ก็จะรายงานผลเป็นสีเขียวปลอม ๆ

ข้อผิดพลาดที่พบบ่อย ห้าม commit โฟลเดอร์ Library, ไฟล์ log, หรือ build output (เช่นโฟลเดอร์ Builds/ จาก script ด้านบน) เข้า git เด็ดขาด สิ่งเหล่านี้มีขนาดใหญ่ ถูกสร้างโดยเครื่อง และจะทำให้ปัญหา "it works on my machine" จากหัวข้อ 1 กลับมาทันที เพราะโฟลเดอร์ Library ที่ถูก commit ไว้แบบเก่าค้างอาจซ่อนบั๊กการ reimport จริง ๆ ไว้ได้ เก็บมันไว้ใน .gitignore แทน หัวข้อ 7 จะพูดถึงวิธีที่ถูกต้องในการทำให้ build เร็วขึ้นโดยไม่ต้อง commit โฟลเดอร์นี้

License Activation บน CI

ก่อนที่ทั้งหมดนี้จะรันได้ ยังมีข้อกำหนดอีกอย่างหนึ่ง: Unity Editor จะไม่รันเลย — แม้แต่ใน batch mode — ถ้าไม่มี license ที่ activate แล้ว build server ที่สร้างใหม่แบบใช้แล้วทิ้ง (ซึ่งเป็นประเด็นหลักของหัวข้อ 2) ไม่เคยเห็น license มาก่อนเลยในทุก ๆ ครั้งที่รัน

สตูดิโอจัดการเรื่องนี้ได้สองแบบ เครื่อง build แบบ self-hosted ที่อยู่ยาวนานสามารถ activate ครั้งเดียว แล้วเก็บไฟล์ license ไว้บนดิสก์ข้ามการรันแต่ละครั้ง เหมือนกับการติดตั้ง Editor ปกติทั่วไป ส่วน runner แบบ cloud ที่อยู่ได้ไม่นาน — เป็น virtual machine ใหม่ทุกครั้งอย่างในตัวอย่าง GitHub Actions ที่ผ่านมา — มักจะ activate ตอนเริ่มต้นของทุก job แทน โดยใช้ credential ที่ดึงมาจาก encrypted secrets storage ของ CI ไม่เคยเขียนตรง ๆ ลงในไฟล์ workflow เลย:

      - name: Activate Unity license
        run: |
          Unity -batchmode -nographics -quit \
            -username "$UNITY_EMAIL" \
            -password "$UNITY_PASSWORD" \
            -serial "$UNITY_SERIAL"
        env:
          UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
          UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
          UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}

secrets.UNITY_EMAIL และตัวอื่น ๆ ที่คล้ายกัน อ้างถึงค่าที่ผู้ดูแล repository เก็บไว้ครั้งเดียวใน encrypted secrets setting ของ GitHub เอง — ไม่เคยอยู่ในไฟล์ YAML เอง และไม่เคยแสดงเป็น plain text ให้เห็นอีกเลยหลังจากบันทึกไว้ รวมถึงคนที่แค่อ่าน workflow ก็มองไม่เห็นด้วย GitHub Actions จะแทนที่ค่าจริงเข้าไปใน environment แค่ตอนที่ job กำลังรันเท่านั้น

Tip รูปแบบนี้เป๊ะ ๆ — credential จริงอยู่แค่ใน encrypted secrets store แล้วถูกอ้างอิงด้วยชื่อจากไฟล์ workflow ที่ตัวมันเองปลอดภัยพอจะ commit และให้คนอ่านสาธารณะได้ — ใช้ได้กับทุก credential ที่ pipeline ต้องการ ไม่ใช่แค่ Unity license เท่านั้น: signing key, upload token สำหรับ distribution service ในหัวข้อ 9, API key ทั้งหลาย ไฟล์ workflow ที่มีรหัสผ่านจริง ๆ พิมพ์ลงไปตรง ๆ คือความผิดพลาดร้ายแรง ไม่ใช่ทางลัด

7. Cache โฟลเดอร์ Library เพื่อไม่ให้ Build ช้าเป็นน้ำแข็งขั้วโลก

asset ทุกชิ้นในโปรเจกต์ Unity — texture, model, audio clip, และ script ทุกอัน — จะถูกประมวลผลครั้งหนึ่งให้กลายเป็นรูปแบบภายในที่ Editor และกระบวนการ build ใช้งานจริง ข้อมูลที่ประมวลผลแล้วนี้อยู่ในโฟลเดอร์ชื่อ Library ซึ่งอยู่ข้าง ๆ Assets ในโปรเจกต์ มันจะถูกสร้างขึ้นใหม่อัตโนมัติทุกครั้งที่หายไป ซึ่งนั่นคือสิ่งที่เกิดขึ้นบน build server พอดี: เพราะหัวข้อ 2 บอกไว้ว่าทุกการรันเริ่มจาก checkout ที่สดใหม่ทั้งหมด และ Library ถูกใส่ไว้ใน .gitignore อย่างถูกต้อง (ตามคำเตือนในหัวข้อก่อนหน้า) มันจึงยังไม่มีอยู่เลยตอนที่ CI job ใหม่เริ่มต้น

การสร้างมันขึ้นใหม่จากศูนย์หมายถึงต้อง reimport asset ทุกชิ้นในโปรเจกต์ทั้งหมดใหม่หมด ก่อนที่ Unity จะเริ่ม compile script หรือ build player ได้ด้วยซ้ำ สำหรับเกมจริง ๆ ที่มี asset หลายพันชิ้น แค่ขั้นตอนนี้อย่างเดียว — ก่อนที่จะเริ่ม build จริง ๆ ด้วยซ้ำ — ก็อาจกินเวลายี่สิบนาที สี่สิบนาที บางทีนานกว่านั้น ในทุก ๆ ครั้งที่รัน CI แม้แต่การเปลี่ยนโค้ดแค่บรรทัดเดียวที่ไม่ได้แตะ asset อะไรเลยก็ตาม

WITHOUT a cached Library folder every CI run starts from an empty Library/ Unity must reimport roughly 4,000 assets from scratch reimport alone: about 35 minutes, before compiling anything WITH a cached Library folder CI restores Library/ from the previous run before Unity opens Unity compares each asset's timestamp/hash against the cache only the handful of assets that actually changed get reimported reimport: about 40 seconds

ทางแก้ตามมาจากไดอะแกรมตรง ๆ เลย: เซฟโฟลเดอร์ Library ไว้ที่ไหนสักที่หลังจากรันสำเร็จ แล้วเรียกคืนมันก่อนที่ Unity จะเริ่มทำงานในการรันครั้งถัดไปด้วยซ้ำ เพื่อให้ Unity ต้อง reimport แค่สิ่งที่เปลี่ยนจริง ๆ ตั้งแต่ครั้งที่แล้วเท่านั้น GitHub Actions มีฟีเจอร์นี้ให้ใช้ในตัว:

      - name: Cache Library folder
        uses: actions/cache@v4
        with:
          path: Library
          key: Library-${{ runner.os }}-${{ hashFiles('Packages/manifest.json') }}
          restore-keys: |
            Library-${{ runner.os }}-
Tip cache key ที่กว้างเกินไป (เช่น string ตายตัวที่ไม่มี hash เลย) เสี่ยงที่จะแอบเสิร์ฟข้อมูลเก่าที่ผิดไปตลอดกาลโดยไม่รู้ตัว ส่วน cache key ที่แคบเกินไป (เช่น รวม commit hash เป๊ะ ๆ เข้าไปด้วย) จะไม่มีวันถูกใช้ซ้ำเลย เพราะมันจะเปลี่ยนทุก commit — ทำลายจุดประสงค์ทั้งหมดไปเลย การตั้ง key จาก "อะไรที่ตัดสินจริง ๆ ว่าข้อมูล import เก่ายังใช้ได้อยู่ไหม" อย่าง packages manifest คือจุดสมดุลที่ใช้งานได้จริง

8. Artifact และ Versioning: รู้แน่ชัดว่าบั๊กมาจาก Build ไหน

ตัว build server เองจะถูกทิ้งไป — หรืออย่างน้อยก็ถูกล้างจนสะอาด — หลังจากแต่ละ job เสร็จ อะไรก็ตามที่ pipeline สร้างขึ้นมาจริง ๆ ต้องถูกเซฟไว้ที่ไหนสักที่ที่อยู่ทนกว่าก่อนที่เรื่องนั้นจะเกิดขึ้น ผลลัพธ์ที่ถูกเซฟไว้นี้เรียกว่า artifact: ไฟล์ .apk, .ipa, หรือโฟลเดอร์ build ของ Windows จริง ๆ ที่การรัน pipeline หนึ่งครั้งสร้างขึ้นมา แล้วถูกเก็บไว้เพื่อให้ดาวน์โหลด ตรวจสอบ หรือส่งให้ tester ได้ในภายหลัง

กองของ artifact จะมีประโยชน์ก็ต่อเมื่อแยกแต่ละอันออกจากกันได้อย่างแม่นยำเท่านั้น ข้อมูลสองอย่างที่ทำให้เป็นไปได้เมื่อใช้ร่วมกัน:

เมื่อฝังทั้งสองอย่างนี้เข้าไปใน build โดยตรง มันหมายความว่ารายงานบั๊กไม่ต้องพูดว่า "เวอร์ชันจากช่วงสัปดาห์ที่แล้ว" อีกต่อไป — แต่บอกได้เป๊ะ ๆ ว่าต้อง checkout commit ไหนเพื่อ reproduce โค้ดตัวเดียวกับที่ tester รันอยู่เป๊ะ ๆ นี่คือขั้นตอน version-stamping ที่เพิ่มเข้าไปใน BuildScript จากหัวข้อ 6 ซึ่งรันก่อนตัว build จริง ๆ:

using System;
using System.Diagnostics;
using System.IO;
using UnityEditor;
using UnityEngine;

public static class VersionStamp
{
    public static string WriteVersionInfo()
    {
        string gitHash = RunGit("rev-parse --short HEAD");
        string buildNumber = Environment.GetEnvironmentVariable("GITHUB_RUN_NUMBER") ?? "local";

        string version = $"{Application.version}+{buildNumber}.{gitHash}";

        // written into Resources so the running game can read it back
        File.WriteAllText("Assets/Resources/version.txt", version);
        AssetDatabase.Refresh();

        UnityEngine.Debug.Log("Stamped build version: " + version);
        return version;
    }

    static string RunGit(string args)
    {
        ProcessStartInfo psi = new ProcessStartInfo("git", args)
        {
            RedirectStandardOutput = true,
            UseShellExecute = false
        };
        using Process p = Process.Start(psi);
        string output = p.StandardOutput.ReadToEnd().Trim();
        p.WaitForExit();
        return output;
    }
}

การเรียก VersionStamp.WriteVersionInfo(); เป็นบรรทัดแรกสุดใน BuildAndroid() จากหัวข้อ 6 หมายความว่าทุก build ที่ script นี้สร้างจะเขียน version string ของตัวเองลงใน Assets/Resources/version.txt ก่อนที่ BuildPipeline.BuildPlayer จะรันด้วยซ้ำ ดังนั้นไฟล์นั้น — และเวอร์ชันที่มันระบุ — จะถูกอบเข้าไปใน build ที่เสร็จสมบูรณ์โดยตรง จากนั้น runtime script เล็ก ๆ ก็สามารถอ่านไฟล์นั้นกลับมาแล้วแสดงไว้ที่ไหนสักที่บนหน้าจอ เช่นมุมหนึ่งของ debug menu:

using UnityEngine;
using UnityEngine.UI;

public class VersionLabel : MonoBehaviour
{
    void Start()
    {
        TextAsset info = Resources.Load<TextAsset>("version");
        string version = info != null ? info.text : "dev build";
        GetComponent<Text>().text = version;
    }
}

ทีนี้ลองไล่ดูว่าสิ่งนี้ให้อะไรกับทีมบ้างเวลามีอะไรพังขึ้นมาจริง ๆ:

git commit a3f9c21 (the exact source code that was built) | v CI run #238 builds it, stamps version "1.4.0+238.a3f9c21" | v artifact uploaded: game-238-a3f9c21.apk | v tester installs it, plays, hits a crash on level 3 | v bug report includes a screenshot showing "1.4.0+238.a3f9c21" | v programmer runs: git checkout a3f9c21 | v programmer is now looking at EXACTLY the code the tester ran

ถ้าไม่มีสิ่งนี้ คำถาม "คุณใช้ build ไหนอยู่" มักถูกตอบด้วย "อันจากวันอังคารมั้ง" แล้วโปรแกรมเมอร์ก็ต้องเดาช่วง commit แทนที่จะ checkout commit เดียวเป๊ะ ๆ — เป็นการตามหาที่แพงและหลีกเลี่ยงได้แบบเดียวกับในหัวข้อ 1 แค่ย้ายจาก "commit ไหนทำให้ build พัง" ไปเป็น "commit ไหนมีบั๊กนี้" เท่านั้นเอง

9. Distribution ให้ Tester: TestFlight, Google Play Internal Testing, itch.io

ขั้นตอนสุดท้ายของ pipeline ในหัวข้อ 3 คือ "upload มันไปไว้ที่ที่ tester ติดตั้งได้" ที่ตรงนั้นสำคัญมาก — .ipa ที่ sign แล้วและมี version แล้ว แต่วางอยู่บน build server ที่ไม่มีใครเข้าถึงได้ ก็แทบไม่ต่างอะไรกับไม่มี build เลย distribution service คือสิ่งที่พา artifact ที่เสร็จแล้วจาก pipeline ไปลงบนอุปกรณ์จริงของ tester ได้จริง ๆ โดยไม่ต้องมีใครส่งไฟล์ใหญ่ ๆ ทางอีเมลเอง หรือต่อสายโทรศัพท์เข้ากับโน้ตบุ๊ก

CI pipeline finishes: a signed, versioned build is ready | v the upload stage (Section 3, stage 7) branches by platform | +----------------+-------------------+ | | | v v v TestFlight Google Play itch.io (iOS build) internal testing (PC/Mac/Linux/web build) | | | v v v tester's tester's tester downloads iPhone gets Android phone or plays directly a notification gets it through in a browser in the the ordinary TestFlight app Play Store app

ในระดับแนวคิด ทั้งสามอย่างนี้แก้ปัญหาเดียวกันแต่คนละแพลตฟอร์ม:

จุดร่วมของทั้งสามอย่าง: ไม่มีอันไหนต้องการให้คนย้ายไฟล์ไปหา tester เองด้วยมือ ขั้นตอน upload ของ pipeline จะ push ตรงไปที่ที่ tester รู้อยู่แล้วว่าต้องไปดู โดยอัตโนมัติ ทุกครั้งที่ build ผ่านขั้นตอนก่อนหน้าทั้งหมด — นี่แหละคือสิ่งที่ทำให้เป็นไปได้จริง ๆ ที่จะเอา build ใหม่ ๆ ที่ทดสอบได้ไปวางต่อหน้าคนได้ทุกวัน หรือแม้แต่วันละหลายครั้ง แทนที่จะทำแค่ก่อนถึง milestone ใหญ่ ๆ เท่านั้น

10. CD และ Staged Rollout สำหรับเกมที่เปิดให้บริการแล้ว

continuous deployment (CD ซึ่งแยกจาก CI แต่สร้างต่อยอดขึ้นมาจากมัน) คือกระบวนการอัตโนมัติที่เอา build ที่ผ่าน CI มาแล้ว ไปวางต่อหน้าผู้ใช้จริง ๆ — ไม่ใช่แค่ tester จากหัวข้อ 9 แต่เป็นฐานผู้เล่นจริงของเกมที่เปิดให้บริการแล้ว เรื่องนี้ต้องระมัดระวังจริง ๆ: ความผิดพลาดที่ไปถึง tester เสียเวลาไปบ้าง แต่ความผิดพลาดที่ไปถึงผู้เล่นจริงทุกคนอาจเสียเงินจริง เสียความเชื่อใจจริง หรือทำให้ save file พังสำหรับคนที่ไม่ใช่ tester และไม่ได้สมัครมาเพื่อหาบั๊กเลย

คำตอบมาตรฐานคือ staged rollout (บางทีก็เรียกว่า canary release ตามธรรมเนียมเก่าที่เอานกคีรีบูนลงไปในเหมืองถ่านหินเพื่อเป็นสัญญาณเตือนล่วงหน้า): แทนที่จะชิป patch ใหม่ให้ผู้เล่นทุกคนพร้อมกัน ก็ปล่อยให้ผู้เล่นสัดส่วนเล็ก ๆ ก่อน แล้วจับตาดู metric จริง ๆ — crash rate, error log, server load, รายงานจากผู้เล่น — เป็นระยะเวลาหนึ่ง แล้วค่อยขยายไปทีละขั้น

Day 1: 1% of live players get the new patch -- watch crash rate Day 2: crash rate looks normal -> ramp to 10% -- keep watching Day 4: still normal -> ramp to 50% Day 6: still normal -> ship to 100% At ANY point, if crash rate or error reports spike: stop the rollout immediately roll back to the last known-good version only the small percentage who already had it were ever affected

คุณค่าทั้งหมดของ staged rollout อยู่ที่ส่วนสุดท้ายนั้น: มันจำกัด blast radius ของความผิดพลาด pipeline ทุกตัวในบทนี้ ต่อให้สร้างมาดีแค่ไหน สุดท้ายก็จะปล่อยให้ปัญหาจริง ๆ หลุดผ่านไปได้สักวันหนึ่งอยู่ดี — CI และ test จับความผิดพลาดได้ส่วนใหญ่ ไม่ใช่ทั้งหมดจริง ๆ staged rollout ไม่ใช่วิธีที่ทำให้ไม่มีวันชิป build แย่ ๆ ออกไปเลย แต่เป็นวิธีที่ทำให้แน่ใจว่าเมื่อมันเกิดขึ้นอย่างหลีกเลี่ยงไม่ได้ มันจะกระทบผู้เล่นแค่หนึ่งเปอร์เซ็นต์เป็นเวลาไม่กี่ชั่วโมง แทนที่จะกระทบผู้เล่นร้อยเปอร์เซ็นต์แบบไม่มีกำหนด

ข้อผิดพลาดที่พบบ่อย สร้างระบบ staged rollout ที่ไม่มีขั้นตอน rollback ที่ง่ายและเร็ว staged rollout ที่ขยายไปข้างหน้าได้อย่างเดียว โดยไม่มีวิธีที่สะอาดในการดึงผู้เล่นที่อยู่บนเวอร์ชันใหม่แล้วกลับไปเวอร์ชันเก่าที่ดีอยู่ ก็ให้แค่สัญญาณเตือนล่วงหน้าเรื่องปัญหา โดยไม่ได้ให้วิธีลงมือทำตามสัญญาณนั้นได้เร็วจริง ๆ เส้นทาง rollback ไม่ใช่งานเสริมที่เลือกทำก็ได้ — แต่เป็นเหตุผลทั้งหมดที่ทำให้ staged rollout ปลอดภัยกว่าการชิปให้ทุกคนพร้อมกันทีเดียว

11. ทำไมเวลา Build ถึงส่งผลต่อขวัญกำลังใจของทีม

ทุกอย่างที่พูดมาก่อนหน้านี้ในบทนี้ — การ cache (หัวข้อ 7), การแยก push แบบเร็วออกจาก nightly build (หัวข้อ 5) — เป็นเรื่องทางเทคนิคส่วนหนึ่ง และเป็นอีกเรื่องหนึ่งไปเลยอีกส่วนหนึ่ง: ความรู้สึกทุก ๆ วันของการเป็นโปรแกรมเมอร์ที่ต้องรอ build

build ที่เร็วทำให้โปรแกรมเมอร์อยู่ในสิ่งที่มักเรียกกันว่า flow state ได้ — ความสนใจที่จดจ่อต่อเนื่องกับปัญหาเดียว push, รอสักสองสามนาที, ดูผลลัพธ์, ทำงานต่อ ส่วน build ที่ช้าจะทำลายจังหวะนั้นในแบบที่เจาะจงและมีต้นทุนสูง:

FAST BUILD (2 minutes) push -> wait, stay put -> see result -> keep working total lost focus: about 2 minutes SLOW BUILD (35 minutes) push -> switch to email, chat, or another task -> forget the exact detail the build was checking -> build finishes; the notification is easy to miss -> eventually notice it, switch back -> re-read the diff just to remember what this was about -> only THEN actually check the result total lost focus: 35 minutes of waiting, plus the real cost of switching attention away and back

เวอร์ชัน 35 นาทีไม่ได้เสียแค่ 35 นาทีเท่านั้น การสลับงาน (task-switching) มีต้นทุนจริงของมันเอง — การกลับมาโฟกัสเต็มที่อีกครั้งหลังถูกขัดจังหวะใช้เวลาที่วัดได้มากกว่าแค่ตอนถูกขัดจังหวะเอง และโปรแกรมเมอร์ที่สลับออกไปทำอย่างอื่นระหว่างรอ build มักจะไม่สลับกลับมาทันทีที่มันเสร็จด้วย ลองคูณเรื่องนี้ด้วยโปรแกรมเมอร์ทุกคนในทีม วันละหลาย ๆ ครั้ง ตลอดหลายเดือนของโปรดักชัน แล้ว build ที่ช้าก็จะค่อย ๆ กลายเป็นหนึ่งในต้นทุนแฝงที่ใหญ่ที่สุดของโปรเจกต์ — ไม่ได้วัดจากเหตุการณ์ใหญ่ครั้งเดียว แต่วัดจากทีมที่เหนื่อยขึ้นนิดหน่อย กระจัดกระจายขึ้นนิดหน่อย และชิปงานช้าลงนิดหน่อย ทุก ๆ วัน

นี่แหละคือเหตุผลที่บทนี้ใช้เวลาจริงจังกับการ cache โฟลเดอร์ Library, การแยกเช็คที่แพงออกไปเป็น job กลางคืน, และการทำให้ pipeline ที่ trigger จาก push เบาที่สุดเท่าที่จะทำได้อย่างสมเหตุสมผล ไม่มีอันไหนเลยที่เป็นเรื่องความสะดวกของ build server — แต่เป็นเรื่องการปกป้อง fast feedback loop จากหัวข้อ 4 ให้กับมนุษย์ที่พึ่งพามันอยู่ เพราะทีมที่เชื่อใจว่า CI ของตัวเองเร็วจะใช้มันอยู่ตลอด ส่วนทีมที่กลัว build ช้าจะค่อย ๆ เริ่มหลีกเลี่ยงมันไปเงียบ ๆ ซึ่งพาความเสี่ยงแบบ "it works on my machine" กลับมาเป๊ะ ๆ ซึ่งเป็นสิ่งที่บทนี้ทั้งหมดมีไว้เพื่อป้องกันตั้งแต่แรก

12. อภิธานศัพท์

13. แบบฝึกหัด

แบบฝึกหัดที่ 1 — หา Trigger ที่หายไป เพื่อนร่วมทีมเขียน workflow ด้านล่างนี้ขึ้นมา โดยต้องการให้จับ commit ที่พังได้ภายในไม่กี่นาทีแบบที่หัวข้อ 4 อธิบายไว้ แต่มีบางอย่างเกี่ยวกับ block on: ที่ทำให้เรื่องนั้นไม่มีวันเกิดขึ้น อะไรที่หายไป และคุณจะเพิ่มอะไรเข้าไป?
name: Nightly Only

on:
  schedule:
    - cron: '0 2 * * *'

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: dotnet build
Show answer

block on: ของ workflow นี้มีแค่ trigger แบบ schedule เท่านั้น ซึ่งหมายความว่ามันรันวันละครั้งตอน 02:00 UTC และไม่รันตอบสนองต่อการ push จริง ๆ เลย commit ที่ push ตอน 9 โมงเช้าจะไม่ถูกเช็คจนกว่าจะถึงคืนถัดไปเป็นอย่างเร็วที่สุด — ตรงข้ามกับ fast feedback loop ของหัวข้อ 4 เลยแบบสุด ๆ และใกล้เคียงกับปัญหา "ไม่มีใครสังเกตเห็นอยู่หลายวัน" จากหัวข้อ 1 มากกว่ามาก

ทางแก้คือเพิ่ม trigger แบบ push (และมักจะรวม pull_request ด้วย) เข้าไปคู่กับ schedule เดิม เพราะ block on: ของ workflow เดียวสามารถระบุ trigger ได้มากกว่าหนึ่งอันพร้อมกัน:

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]
  schedule:
    - cron: '0 2 * * *'

ทีนี้ job เดียวกันจะรันทันทีทุกครั้งที่ push หรือมี pull request (เลนเร็วจากหัวข้อ 5) และยังรันตาม schedule กลางคืนด้วยเหมือนเดิม ในโปรเจกต์จริง trigger กลางคืนมักจะเล็งไปที่ job แยกต่างหากที่หนักกว่า — platform matrix เต็มรูปแบบและ test ยาว ๆ จากหัวข้อ 5 — ในขณะที่ trigger จาก push ยังคงเบาและเร็วอยู่ ในตัวอย่างนี้ trigger ทั้งสองบังเอิญเล็งไปที่ job ง่าย ๆ ตัวเดียวกัน ซึ่งก็ยังถือเป็นการปรับปรุงที่ดีขึ้นจริง ๆ เมื่อเทียบกับการไม่รันเลยจนกว่าจะถึงตี 2 ของคืนถัดไป

แบบฝึกหัดที่ 2 — เขียนฟังก์ชัน Version String โดยใช้รูปแบบจากหัวข้อ 8 เขียน static method เล็ก ๆ ใน C# ชื่อ BuildVersionString(int buildNumber, string gitHash) ที่คืนค่า version string ในสไตล์เดียวกับ "1.4.0+238.a3f9c21" โดยสมมติว่า Application.version ถูกตั้งไว้เป็น "1.4.0" ใน Player Settings อยู่แล้ว จากนั้นไล่ trace ว่ามันจะคืนค่าอะไรสำหรับ buildNumber = 512 และ gitHash = "7c1e0af"
Show answer
static string BuildVersionString(int buildNumber, string gitHash)
{
    return $"{Application.version}+{buildNumber}.{gitHash}";
}

Trace: เมื่อ Application.version ถูกตั้งไว้เป็น "1.4.0" อยู่แล้ว การเรียก BuildVersionString(512, "7c1e0af") จะแทนค่าแต่ละตัวลงใน interpolated string ตามลำดับ: Application.version กลายเป็น "1.4.0", buildNumber กลายเป็น 512, และ gitHash กลายเป็น "7c1e0af" ได้ผลลัพธ์เป็น "1.4.0+512.7c1e0af" ตามรูปแบบของหัวข้อ 8 string นี้จะถูกเขียนลงใน Assets/Resources/version.txt ก่อนที่ build จริง ๆ จะรัน ทำให้มันถูกอบเข้าไปใน artifact ตัวนั้นเป๊ะ ๆ และอ่านได้ตอน runtime ด้วย script อย่าง VersionLabel

แบบฝึกหัดที่ 3 — ออกแบบ Cache Key ให้ถูกต้อง build CI ของทีมหนึ่งใช้เวลา 25 นาที เพราะยังไม่มีใครตั้งค่า Library caching จากหัวข้อ 7 เลย เขียนขั้นตอน cache ที่พวกเขาควรเพิ่มเข้าไป (ตามรูปแบบจากหัวข้อ 7) และอธิบายสักหนึ่งหรือสองประโยคว่าทำไม key ของ cache ควรรวม hash ของ Packages/manifest.json เข้าไปด้วย แทนที่จะใช้แค่ string ตายตัวอย่าง "unity-library-cache"
Show answer
      - name: Cache Library folder
        uses: actions/cache@v4
        with:
          path: Library
          key: Library-${{ runner.os }}-${{ hashFiles('Packages/manifest.json') }}
          restore-keys: |
            Library-${{ runner.os }}-

ถ้า key เป็นแค่ string ตายตัวอย่าง "unity-library-cache" โดยไม่มี hash เลย โฟลเดอร์ Library ที่ cache ไว้ตัวเดิมเป๊ะ ๆ จะถูกเรียกคืนมาตลอดไป แม้กระทั่งหลังจากมีคนเพิ่มหรืออัปเดต package ใน Packages/manifest.json แล้ว Unity ก็จะทำงานจากข้อมูล import ที่ยังไม่รู้ว่ามี package ใหม่อยู่ ซึ่งอาจทำให้เกิด error แบบ missing-reference หรือ missing-type ที่งง ๆ ซึ่งไม่เกี่ยวอะไรเลยกับการเปลี่ยนโค้ดจริง ๆ ที่ทำให้ build นี้เกิดขึ้น การ hash ไฟล์ manifest หมายความว่าการเปลี่ยน dependency จริง ๆ จะสร้าง key ที่ต่างออกไปโดยอัตโนมัติ ดังนั้น cache จะถูกมองว่าใช้ไม่ได้อย่างถูกต้อง และ Unity ก็จะ reimport สิ่งที่ dependency ใหม่ต้องการอย่างเหมาะสม — ในขณะที่ commit ปกติทุกอันที่ไม่ได้แตะ dependency ก็ยังได้ cache hit ที่เร็วและถูกจากหัวข้อ 7 อยู่เหมือนเดิม

← กลับไปหน้ารวมบท