ตลอดมา คำถามว่า "เกมทำงานไหม" ส่วนใหญ่หมายถึง "มันทำงานไหมตอนที่เรากด play บนคอมพิวเตอร์ของเราเอง" คำถามแบบนี้ใช้ไม่ได้อีกต่อไปทันทีที่มีคนมากกว่าหนึ่งคนแตะโปรเจกต์เดียวกัน เกมจริง ๆ ถูกสร้างโดยทีม ถูกทดสอบโดยคนที่ไม่ใช่โปรแกรมเมอร์ที่เขียนโค้ดตรงนั้น และสุดท้ายก็ต้องถูกติดตั้งบนฮาร์ดแวร์ที่ไม่มีใครในทีมเป็นเจ้าของ บทนี้พูดถึงกลไกที่สตูดิโอสร้างขึ้นมา เพื่อให้การเปลี่ยนแปลงที่ใครสักคนทำบนโน้ตบุ๊กของตัวเองสามารถพิสูจน์ได้ว่าใช้งานได้จริงในที่อื่นด้วย โดยอัตโนมัติ ภายในไม่กี่นาที และจบลงที่โทรศัพท์ของ tester ได้โดยไม่มีใครต้องก็อปปี้ไฟล์ด้วยมือ ไม่มีอะไรในนี้ที่แปลกใหม่เลย — มันคือโครงสร้างพื้นฐานธรรมดา ๆ ที่ทุกสตูดิโอที่เคยชิปอะไรออกไปใช้กัน ตั้งแต่ทีม indie สองคนไปจนถึงโปรดักชันระดับ HoYoverse
ลองนึกภาพโปรแกรมเมอร์สามคนในทีมเล็ก ๆ ทีมเดียวกัน 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 ที่พังแล้วไม่มีใครสังเกตเห็นอยู่หลายวันจะไม่ใช่ปัญหาเล็ก ๆ อีกต่อไป — เพราะจะมีงานอีกเยอะถูกสร้างทับลงไปบนโค้ดที่พังอยู่ ก่อนที่จะมีใครรู้ตัวด้วยซ้ำว่ามีอะไรผิดปกติ
นั่นคือเวลาหลายชั่วโมงของโปรแกรมเมอร์ที่ต้องเสียไปกับการตามหาความผิดพลาด ซึ่งถ้าถูกจับได้ตั้งแต่วันที่มันเกิดขึ้น ก็คงใช้เวลาแก้แค่ไม่กี่นาที — เพราะคนที่เขียนโค้ดนั้นยังจำได้แม่นว่าเพิ่งเปลี่ยนอะไรไป ที่เหลือของบทนี้จะพูดถึงการปิดช่องว่างระหว่าง "ความผิดพลาดเกิดขึ้น" กับ "ความผิดพลาดถูกจับได้"
แนวคิดหลักนั้นเรียบง่ายจนแทบจะขายหน้า: เลิกเชื่อเครื่องของนักพัฒนาคนใดคนหนึ่งในการตอบคำถามว่า "โปรเจกต์นี้ build ได้จริงไหม" แล้วใช้เครื่องแยกต่างหากอีกเครื่องหนึ่งแทน — เรียกว่า build server (หรือเรียกว่า CI server หรือ CI runner ก็ได้ โดย CI ย่อมาจาก continuous integration ซึ่งจะพูดถึงในหัวข้อถัดไป) — ซึ่งมีหน้าที่เดียวคือ checkout โปรเจกต์จาก git มาแบบสด ๆ ใหม่เอี่ยมทั้งหมด แล้วลอง build ดู โดยไม่มีอะไรหลงเหลือจากการลองครั้งก่อนหน้าเลย
build server ถูกออกแบบให้น่าเบื่อโดยตั้งใจ มันไม่มี tool ที่ใครชอบเป็นการส่วนตัว ไม่มีโค้ดทดลองที่เขียนค้างไว้ ไม่มี environment variable ที่ลืมตั้งไว้ ทุกครั้งที่มันรัน มันจะเริ่มจาก state ที่สะอาดเหมือนเดิมทุกครั้ง และเห็นแค่สิ่งที่ถูก commit เข้า repository จริง ๆ เท่านั้น คุณสมบัติข้อเดียวนี้แหละที่ทำให้คำตอบของมันน่าเชื่อถือ: ถ้า build server บอกว่าโปรเจกต์ build ผ่าน นั่นแปลว่ามันเป็นจริงสำหรับใครก็ตามที่ checkout commit เดียวกัน ไม่ใช่แค่บังเอิญเป็นจริงสำหรับโน้ตบุ๊กเครื่องเดียว
build server เพียงลำพังก็แค่เครื่องที่ build โปรเจกต์ได้หนึ่งครั้งตามต้องการ สี่หัวข้อถัดไปจะพูดถึงว่าอะไรเกิดขึ้นจริง ๆ ระหว่างการ build นั้น และตัว trigger ที่ทำให้มันรันถูกทำให้อัตโนมัติได้ยังไง จนไม่มีใครต้องมานั่งจำว่าต้องสั่งรันเอง
build pipeline คือลำดับขั้นตอนที่ build server รันเรียงกัน ตั้งแต่ "นี่คือ commit หนึ่งอัน" ไปจนถึง "นี่คือ build ที่เสร็จสมบูรณ์ ติดตั้งได้แล้ว" pipeline จริง ๆ ของแต่ละสตูดิโอจะแตกต่างกันไปบ้าง แต่เกือบทั้งหมดมีรูปร่างแบบเดียวกันนี้:
ไล่ดูแต่ละขั้นตอน:
Packages/manifest.json, NuGet package สำหรับโปรเจกต์ .NET, third-party library ต่าง ๆ — จะถูกดาวน์โหลดมา ขั้นตอนนี้มีอยู่เพราะปกติแล้ว dependency จะไม่ถูก commit เข้า git ตรง ๆ มีแค่รายการว่าต้องใช้เวอร์ชันไหนเท่านั้นที่ถูก commit.apk, iOS .ipa, Windows .exe พร้อม data folder ของมัน สตูดิโอที่ชิปหลายแพลตฟอร์มจะทำขั้นตอนนี้ซ้ำหนึ่งครั้งต่อหนึ่งแพลตฟอร์ม เพราะแต่ละแพลตฟอร์มต้องการ build แยกของตัวเองรายการนี้ยังไม่มีอะไรเจาะจงกับ Unity เลย — รูปร่างเดียวกันนี้ใช้ได้กับ mobile app, เว็บไซต์ หรือ desktop tool ก็ได้ หัวข้อ 6 ถึง 8 จะทำให้หลายขั้นตอนในนี้เป็นรูปธรรมเจาะจงกับโปรเจกต์ Unity โดยเฉพาะ
continuous integration (มักย่อว่า CI) คือแนวปฏิบัติที่รัน build pipeline จากหัวข้อ 3 โดยอัตโนมัติ ทุกครั้งที่มีคน push commit ออกมา — ไม่ใช่สัปดาห์ละครั้งตอนที่มีใครนึกขึ้นได้ ไม่ใช่แค่ก่อนปล่อยเวอร์ชันเท่านั้น แต่ทุกครั้งที่ push โดยไม่ต้องมีมนุษย์มา trigger เอง ชื่อของมันหมายถึงการ integrate (merge) การเปลี่ยนแปลงของทุกคนเข้าด้วยกันตลอดเวลา แล้วตรวจสอบทุกครั้งว่าผลลัพธ์ยังใช้งานได้อยู่
คุณค่าของ CI แทบทั้งหมดอยู่ที่เรื่องจังหวะเวลา ลองเทียบดูว่า build ที่พังจากหัวข้อ 1 จะเป็นยังไงถ้าไม่มี CI กับถ้ามี CI:
สิ่งนี้มักถูกเรียกว่า 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
ไล่อ่านทีละบรรทัด:
name: CI — ชื่อที่แสดงสำหรับ workflow นี้ใน interface ของ GitHub เอง มีไว้สำหรับมนุษย์อ่านล้วน ๆon: — ตัว trigger workflow นี้จะรันเมื่อมีคน push เข้า branch main และรันเมื่อมี pull request ที่เล็งไปที่ main ด้วย นี่คือสิ่งที่ทำให้มันอัตโนมัติ — ไม่มีใครต้องมานั่งจำว่าต้องกดปุ่มอะไรjobs: ตามด้วย build-and-test: — กำหนด job หนึ่งตัวที่มีชื่อ workflow หนึ่งตัวสามารถมีหลาย job รันพร้อมกันได้ แต่ตัวอย่างนี้มีแค่ job เดียวruns-on: ubuntu-latest — นี่คือ build server จากหัวข้อ 2 GitHub จะยื่น virtual machine ของ Linux ที่ใหม่แกะกล่องและใช้แล้วทิ้งให้กับ job นี้ ซึ่งไม่เคยรันอะไรมาก่อนเลยsteps: — รายการคำสั่งที่เรียงลำดับ รันทีละอันบนเครื่องที่สดใหม่นั้นactions/checkout@v4 — นี่คือขั้นตอน "pull latest code" จากหัวข้อ 3: มัน clone commit ที่ทำให้เกิดการรันนี้ลงบนเครื่องที่สดใหม่นั้นเป๊ะ ๆdotnet restore — ขั้นตอน "restore packages" จากหัวข้อ 3: ดาวน์โหลด dependency ทั้งหมดที่ไฟล์ package ของโปรเจกต์ระบุไว้dotnet build --no-restore — ขั้นตอน "compile" จากหัวข้อ 3dotnet test --no-build — ขั้นตอน "run tests" จากหัวข้อ 3ถ้ามีขั้นตอนไหนจบด้วยความล้มเหลว (compile error, test ที่ fail) GitHub Actions จะหยุด job ตรงนั้นทันที ทำเครื่องหมายเป็นสีแดง และ — ขึ้นอยู่กับว่าทีมตั้งค่าไว้ยังไง — อาจบล็อกไม่ให้การเปลี่ยนแปลงนั้นถูก merge จนกว่าจะแก้เสร็จ นี่คือกลไกทั้งหมด: ไม่มีขั้นตอนไหนในนี้ที่ซับซ้อนด้วยตัวเอง คุณค่าทั้งหมดมาจากการที่มันรันอัตโนมัติ ทุกครั้งที่ push โดยไม่ต้องมีใครมาขอให้รันเลย
ตัวอย่างในหัวข้อ 4 รันเสร็จในเวลาไม่ถึงนาทีด้วยซ้ำ แต่ pipeline เต็มรูปแบบของโปรเจกต์เกมจริง ๆ ไม่ได้เป็นแบบนั้น การ build player จริง ๆ สำหรับทุกแพลตฟอร์มที่สตูดิโอชิป, การรัน test suite เต็มชุดแทนที่จะเป็นแค่ subset สั้น ๆ, และการรันเช็คที่ใช้เวลานานอย่าง memory-leak soak test (ปล่อยให้เกมรันค้างไว้หลายชั่วโมงเพื่อดูว่าการใช้หน่วยความจำเพิ่มขึ้นเรื่อย ๆ ไหม) หรือ performance benchmark ก็สามารถกินเวลาเป็นชั่วโมงหรือมากกว่านั้นได้ง่าย ๆ บางทีก็หลายชั่วโมงเลย
การรันทั้งหมดนั้นทุกครั้งที่ push ในทางเทคนิคก็ยังนับเป็น "continuous integration" อยู่ดี แต่มันจะทำลายสิ่งที่ทำให้ CI มีคุณค่าไปเลย นั่นคือ fast feedback loop จากหัวข้อ 4 ไม่มีใครอยากรอสองชั่วโมงครึ่งเพื่อจะรู้ว่าการแก้บั๊กบรรทัดเดียวที่เพิ่ง push ไป compile ผ่านไหม ดังนั้นสตูดิโอส่วนใหญ่จะแบ่ง pipeline ออกเป็นสองระดับ แทนที่จะรันทุกอย่างทุกครั้ง
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
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
แต่ละ flag มีความหมาย:
-batchmode — รันโดยไม่เปิดหน้าต่าง Editor แบบ graphical ตามปกติเลย-quit — ออกจากโปรแกรมอัตโนมัติทันทีที่สิ่งที่สั่งไว้เสร็จ แทนที่จะเปิดค้างรอมนุษย์-nographics — ข้ามการ initialize graphics device ไปเลย เพราะ CI runner มักไม่มี GPU หรือ display driver ให้ initialize ตั้งแต่แรกอยู่แล้ว-projectPath . — โฟลเดอร์ที่มีโปรเจกต์ Unity ที่จะเปิด ในที่นี้คือ "current directory" เพราะ pipeline checkout repository มาไว้ตรงนั้นอยู่แล้ว-executeMethod BuildScript.BuildAndroid — พอ Editor โหลดแบบ headless เสร็จแล้ว ให้เรียก static method ตัวนี้เป๊ะ ๆ นี่คือสะพานเชื่อมระหว่าง command line ทั่วไปของ Unity กับโค้ด build เฉพาะโปรเจกต์ที่แสดงไว้ด้านบน-logFile build.log — เขียนทุกอย่างที่ Editor พิมพ์ออกมาระหว่างการรันนี้ลงไฟล์ เพื่อว่าถ้า build fail จะได้มี log เต็ม ๆ ที่อ่านได้ให้ดูย้อนหลัง แทนที่จะมีแค่ผลลัพธ์ pass/fail เฉย ๆEditorApplication.Exit(1) ใน script ด้านบนคือสิ่งที่เชื่อมความล้มเหลวระดับ Unity กลับไปหา CI จริง ๆ: exit code ปกติของ process ที่เป็น 0 หมายถึงสำเร็จ และ code ที่ไม่ใช่ 0 หมายถึงล้มเหลว ถ้าไม่มีการเรียกแบบนี้ตรง ๆ Unity อาจจบการทำงานเงียบ ๆ พร้อม build ที่ fail แต่ยังคง exit ด้วย code 0 อยู่ดี แล้ว CI pipeline ก็จะรายงานผลเป็นสีเขียวปลอม ๆ
Library, ไฟล์ log, หรือ build output (เช่นโฟลเดอร์ Builds/ จาก script ด้านบน) เข้า git เด็ดขาด สิ่งเหล่านี้มีขนาดใหญ่ ถูกสร้างโดยเครื่อง และจะทำให้ปัญหา "it works on my machine" จากหัวข้อ 1 กลับมาทันที เพราะโฟลเดอร์ Library ที่ถูก commit ไว้แบบเก่าค้างอาจซ่อนบั๊กการ reimport จริง ๆ ไว้ได้ เก็บมันไว้ใน .gitignore แทน หัวข้อ 7 จะพูดถึงวิธีที่ถูกต้องในการทำให้ build เร็วขึ้นโดยไม่ต้อง commit โฟลเดอร์นี้ก่อนที่ทั้งหมดนี้จะรันได้ ยังมีข้อกำหนดอีกอย่างหนึ่ง: 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 กำลังรันเท่านั้น
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 อะไรเลยก็ตาม
ทางแก้ตามมาจากไดอะแกรมตรง ๆ เลย: เซฟโฟลเดอร์ 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 }}-
path: Library — ระบุเป๊ะ ๆ ว่าโฟลเดอร์ไหนที่จะถูกเซฟหลัง job และเรียกคืนก่อน job ถัดไปkey: — cache ที่ต้องการหาเป๊ะ ๆ มันรวมชื่อ operating system ไว้ด้วย (cache ที่ build บน Linux ใช้ซ้ำบน Windows ไม่ปลอดภัย) และ hash ของ Packages/manifest.json — ไฟล์ที่ระบุ package dependency ของโปรเจกต์ — เพื่อให้การเปลี่ยน dependency ทำให้ cache เก่าใช้ไม่ได้อัตโนมัติ แทนที่จะแอบใช้ข้อมูล import เก่าที่เข้ากันไม่ได้ซ้ำเงียบ ๆrestore-keys: — รายการสำรอง ถ้าไม่มี cache ไหนตรงกับ key ปัจจุบันเป๊ะ ๆ (เช่น เพราะ package เวอร์ชันหนึ่งเพิ่งเปลี่ยน) ตัวนี้จะบอกให้ action ถอยไปใช้ cache ล่าสุดที่อย่างน้อยก็ตรงกับ prefix Library-linux- แทนที่จะเริ่มจากศูนย์ โฟลเดอร์ส่วนใหญ่ยังถูกใช้ซ้ำอยู่ดี มีแค่ส่วนที่ต้องเปลี่ยนจริง ๆ เท่านั้นที่จะถูก reimportตัว build server เองจะถูกทิ้งไป — หรืออย่างน้อยก็ถูกล้างจนสะอาด — หลังจากแต่ละ job เสร็จ อะไรก็ตามที่ pipeline สร้างขึ้นมาจริง ๆ ต้องถูกเซฟไว้ที่ไหนสักที่ที่อยู่ทนกว่าก่อนที่เรื่องนั้นจะเกิดขึ้น ผลลัพธ์ที่ถูกเซฟไว้นี้เรียกว่า artifact: ไฟล์ .apk, .ipa, หรือโฟลเดอร์ build ของ Windows จริง ๆ ที่การรัน pipeline หนึ่งครั้งสร้างขึ้นมา แล้วถูกเก็บไว้เพื่อให้ดาวน์โหลด ตรวจสอบ หรือส่งให้ tester ได้ในภายหลัง
กองของ artifact จะมีประโยชน์ก็ต่อเมื่อแยกแต่ละอันออกจากกันได้อย่างแม่นยำเท่านั้น ข้อมูลสองอย่างที่ทำให้เป็นไปได้เมื่อใช้ร่วมกัน:
github.run_numbera3f9c21 build number อย่างเดียวบอกได้แค่ว่ามันเป็น build ที่ 238 เท่านั้น ส่วน git hash บอกเป๊ะ ๆ ว่า source code บรรทัดไหนบ้างที่สร้าง build นั้นขึ้นมาเมื่อฝังทั้งสองอย่างนี้เข้าไปใน 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;
}
}
ทีนี้ลองไล่ดูว่าสิ่งนี้ให้อะไรกับทีมบ้างเวลามีอะไรพังขึ้นมาจริง ๆ:
ถ้าไม่มีสิ่งนี้ คำถาม "คุณใช้ build ไหนอยู่" มักถูกตอบด้วย "อันจากวันอังคารมั้ง" แล้วโปรแกรมเมอร์ก็ต้องเดาช่วง commit แทนที่จะ checkout commit เดียวเป๊ะ ๆ — เป็นการตามหาที่แพงและหลีกเลี่ยงได้แบบเดียวกับในหัวข้อ 1 แค่ย้ายจาก "commit ไหนทำให้ build พัง" ไปเป็น "commit ไหนมีบั๊กนี้" เท่านั้นเอง
ขั้นตอนสุดท้ายของ pipeline ในหัวข้อ 3 คือ "upload มันไปไว้ที่ที่ tester ติดตั้งได้" ที่ตรงนั้นสำคัญมาก — .ipa ที่ sign แล้วและมี version แล้ว แต่วางอยู่บน build server ที่ไม่มีใครเข้าถึงได้ ก็แทบไม่ต่างอะไรกับไม่มี build เลย distribution service คือสิ่งที่พา artifact ที่เสร็จแล้วจาก pipeline ไปลงบนอุปกรณ์จริงของ tester ได้จริง ๆ โดยไม่ต้องมีใครส่งไฟล์ใหญ่ ๆ ทางอีเมลเอง หรือต่อสายโทรศัพท์เข้ากับโน้ตบุ๊ก
ในระดับแนวคิด ทั้งสามอย่างนี้แก้ปัญหาเดียวกันแต่คนละแพลตฟอร์ม:
.ipa ที่ sign แล้วไปที่ App Store Connect Apple ประมวลผลมัน แล้ว tester ที่ถูกเชิญไว้แล้วจะได้รับ notification ในแอป TestFlight และติดตั้งได้ตรง ๆ — ไม่ต้องต่อสาย ไม่ต้องไป trust developer certificate ที่ไม่รู้จักบนเครื่องเอง.aab) ที่ sign แล้วไปที่ track นั้น กลุ่ม tester เล็ก ๆ ที่ถูกอนุมัติไว้ล่วงหน้าจะได้รับมันผ่านแอป Play Store ปกติทั่วไปบนโทรศัพท์ของตัวเอง ซึ่งมีประโยชน์ตรงที่มันใช้เส้นทางการติดตั้งเดียวกับที่ผู้เล่นจริงจะใช้ในที่สุดจุดร่วมของทั้งสามอย่าง: ไม่มีอันไหนต้องการให้คนย้ายไฟล์ไปหา tester เองด้วยมือ ขั้นตอน upload ของ pipeline จะ push ตรงไปที่ที่ tester รู้อยู่แล้วว่าต้องไปดู โดยอัตโนมัติ ทุกครั้งที่ build ผ่านขั้นตอนก่อนหน้าทั้งหมด — นี่แหละคือสิ่งที่ทำให้เป็นไปได้จริง ๆ ที่จะเอา build ใหม่ ๆ ที่ทดสอบได้ไปวางต่อหน้าคนได้ทุกวัน หรือแม้แต่วันละหลายครั้ง แทนที่จะทำแค่ก่อนถึง milestone ใหญ่ ๆ เท่านั้น
continuous deployment (CD ซึ่งแยกจาก CI แต่สร้างต่อยอดขึ้นมาจากมัน) คือกระบวนการอัตโนมัติที่เอา build ที่ผ่าน CI มาแล้ว ไปวางต่อหน้าผู้ใช้จริง ๆ — ไม่ใช่แค่ tester จากหัวข้อ 9 แต่เป็นฐานผู้เล่นจริงของเกมที่เปิดให้บริการแล้ว เรื่องนี้ต้องระมัดระวังจริง ๆ: ความผิดพลาดที่ไปถึง tester เสียเวลาไปบ้าง แต่ความผิดพลาดที่ไปถึงผู้เล่นจริงทุกคนอาจเสียเงินจริง เสียความเชื่อใจจริง หรือทำให้ save file พังสำหรับคนที่ไม่ใช่ tester และไม่ได้สมัครมาเพื่อหาบั๊กเลย
คำตอบมาตรฐานคือ staged rollout (บางทีก็เรียกว่า canary release ตามธรรมเนียมเก่าที่เอานกคีรีบูนลงไปในเหมืองถ่านหินเพื่อเป็นสัญญาณเตือนล่วงหน้า): แทนที่จะชิป patch ใหม่ให้ผู้เล่นทุกคนพร้อมกัน ก็ปล่อยให้ผู้เล่นสัดส่วนเล็ก ๆ ก่อน แล้วจับตาดู metric จริง ๆ — crash rate, error log, server load, รายงานจากผู้เล่น — เป็นระยะเวลาหนึ่ง แล้วค่อยขยายไปทีละขั้น
คุณค่าทั้งหมดของ staged rollout อยู่ที่ส่วนสุดท้ายนั้น: มันจำกัด blast radius ของความผิดพลาด pipeline ทุกตัวในบทนี้ ต่อให้สร้างมาดีแค่ไหน สุดท้ายก็จะปล่อยให้ปัญหาจริง ๆ หลุดผ่านไปได้สักวันหนึ่งอยู่ดี — CI และ test จับความผิดพลาดได้ส่วนใหญ่ ไม่ใช่ทั้งหมดจริง ๆ staged rollout ไม่ใช่วิธีที่ทำให้ไม่มีวันชิป build แย่ ๆ ออกไปเลย แต่เป็นวิธีที่ทำให้แน่ใจว่าเมื่อมันเกิดขึ้นอย่างหลีกเลี่ยงไม่ได้ มันจะกระทบผู้เล่นแค่หนึ่งเปอร์เซ็นต์เป็นเวลาไม่กี่ชั่วโมง แทนที่จะกระทบผู้เล่นร้อยเปอร์เซ็นต์แบบไม่มีกำหนด
ทุกอย่างที่พูดมาก่อนหน้านี้ในบทนี้ — การ cache (หัวข้อ 7), การแยก push แบบเร็วออกจาก nightly build (หัวข้อ 5) — เป็นเรื่องทางเทคนิคส่วนหนึ่ง และเป็นอีกเรื่องหนึ่งไปเลยอีกส่วนหนึ่ง: ความรู้สึกทุก ๆ วันของการเป็นโปรแกรมเมอร์ที่ต้องรอ build
build ที่เร็วทำให้โปรแกรมเมอร์อยู่ในสิ่งที่มักเรียกกันว่า flow state ได้ — ความสนใจที่จดจ่อต่อเนื่องกับปัญหาเดียว push, รอสักสองสามนาที, ดูผลลัพธ์, ทำงานต่อ ส่วน build ที่ช้าจะทำลายจังหวะนั้นในแบบที่เจาะจงและมีต้นทุนสูง:
เวอร์ชัน 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" กลับมาเป๊ะ ๆ ซึ่งเป็นสิ่งที่บทนี้ทั้งหมดมีไว้เพื่อป้องกันตั้งแต่แรก
on: ที่ทำให้เรื่องนั้นไม่มีวันเกิดขึ้น อะไรที่หายไป และคุณจะเพิ่มอะไรเข้าไป?
name: Nightly Only
on:
schedule:
- cron: '0 2 * * *'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: dotnet build
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 ของคืนถัดไป
BuildVersionString(int buildNumber, string gitHash) ที่คืนค่า version string ในสไตล์เดียวกับ "1.4.0+238.a3f9c21" โดยสมมติว่า Application.version ถูกตั้งไว้เป็น "1.4.0" ใน Player Settings อยู่แล้ว จากนั้นไล่ trace ว่ามันจะคืนค่าอะไรสำหรับ buildNumber = 512 และ gitHash = "7c1e0af"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
key ของ cache ควรรวม hash ของ Packages/manifest.json เข้าไปด้วย แทนที่จะใช้แค่ string ตายตัวอย่าง "unity-library-cache" - 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 อยู่เหมือนเดิม