Fork một thư viện chỉ mất một lệnh. Phần tốn công là giữ cho bản fork minh bạch: nghĩa vụ giấy phép, nguồn gốc của nó, và cách tiếp tục nhận bản sửa từ bản gốc. Mình fork package yoastseo (giấy phép GPL) của Yoast rồi publish lên npm, và có bốn chỗ cần ra quyết định thật. Đó là giấy phép và thông báo sửa đổi, một liên kết lịch sử với upstream để mỗi lần merge là merge ba chiều, việc đồng bộ hằng tháng, và các quy tắc giữ cho lần đồng bộ nào cũng nhẹ.
Bản fork là seo-analyzer, publish thành @vuminhhoa/seo-analyzer vào tháng 10/2026. Nó giữ phần xử lý ngôn ngữ của Yoast và thay toàn bộ tiêu chí chấm điểm; việc soát các tiêu chí đó theo tài liệu của Google nằm ở một bài khác. Bài này chỉ nói về chuyện làm một bản fork.
GPL yêu cầu gì ở một bản fork được phát hành
yoastseo dùng giấy phép GPL, nên bản fork cũng vậy: package ghi GPL-3.0-or-later. Chọn một giấy phép thoáng hơn chưa bao giờ là lựa chọn, vì bản sửa đổi của code GPL chỉ được phân phối theo GPL. Mục 5 của GPLv3 thêm hai yêu cầu cụ thể cho bản sửa đổi: phải có thông báo dễ thấy rằng bạn đã sửa, kèm ngày liên quan, và thông báo rằng bản này được phát hành theo GPL.
Trên thực tế, hai yêu cầu đó gói lại thành một file NOTICE.md, đóng vào package npm cạnh LICENSE:
"files": ["build/", "types/", "LICENSE", "NOTICE.md"]
File này nói dự án là bản sửa đổi của cái gì, ai giữ bản quyền bản gốc, fork từ commit upstream nào và đồng bộ lần cuối tới đâu, kèm danh sách ngắn các thay đổi chính. Danh sách đó quan trọng hơn vẻ ngoài của nó: "một bản fork của X" không cho người đọc biết khác ở đâu, trong khi giấy phép yêu cầu bạn nói ra là có thay đổi.
Nghĩa vụ giấy phép đi theo cả người dùng package. Theo GPL FAQ, một app đóng gói thư viện GPL rồi phân phối là một tác phẩm kết hợp, chịu GPL. Mình không phải luật sư và đây không phải tư vấn pháp lý, nhưng đó là điều đầu tiên mình muốn biết trước khi thêm dependency này vào một app mã nguồn đóng. Vì vậy README ghi giấy phép ngay mấy dòng đầu, không để ở cuối trang.
Code bên thứ ba giữ giấy phép riêng của nó
Bản thân package của Yoast đã chứa code theo giấy phép khác, và bản fork thừa hưởng tất cả. Đi qua các file có header giấy phép, mình tìm ra ba nhóm, giờ được liệt kê thành bảng trong NOTICE.md:
| Đường dẫn | Nguồn | Giấy phép |
|---|---|---|
src/vendor/turkishStemmer.js |
Snowball JavaScript | BSD-3-Clause |
languages/*/helpers/internal/stem.js |
Các bộ tách gốc từ Snowball, Porter và khác | BSD hoặc MIT, tuỳ file |
languages/hu/config/internal/* |
Danh sách từ lấy từ một corpus Common Crawl | CC BY-SA 4.0 |
Các file đó giữ nguyên header gốc. Cái bảng có mặt để người kiểm tra dependency không phải grep 20 thư mục ngôn ngữ mới biết có một danh sách từ mang điều khoản share-alike.
Ghi lại điểm fork để merge thành merge ba chiều
Repo không bắt đầu bằng nút Fork trên GitHub. Commit đầu tiên là một bản sao mã nguồn, nghĩa là git không hề biết src/ và packages/yoastseo/src của Yoast có liên quan. Mỗi lần đồng bộ sau này sẽ so hai cây không liên quan, và mọi file cả hai bên đều đã sửa sẽ conflict cả file.
Cách sửa gồm hai phần. Thứ nhất, một nhánh tên upstream chỉ chứa lịch sử package của Yoast, với đường dẫn được viết lại cho khớp repo này, dựng bằng git-filter-repo:
git -C "$WORK/yoast" filter-repo --force --quiet \
--path packages/yoastseo/src/ \
--path-rename packages/yoastseo/src/:src/
Thứ hai, một commit merge ghi lại nguồn gốc của bản fork mà không đổi file nào. Đó chính là việc của chiến lược merge ours: kết quả giữ nguyên cây hiện tại và có thêm commit upstream d30b500 (13/1/2025) làm parent thứ hai.
git merge -s ours d30b500 -m "Record fork point from upstream yoastseo (d30b500, 2025-01-13)"
git diff giữa commit merge và parent thứ nhất của nó rỗng, và đó đúng là mục đích. Từ đó trở đi, git merge upstream tìm được một merge base thật, và chỉ những dòng mà cả hai bên đã sửa kể từ d30b500 mới có thể conflict. Lần đồng bộ đầu tiên, tới commit upstream afe80002 (13/10/2026), đụng tới 210 file và đi qua như một pull request bình thường.
Đồng bộ chỉ được đi tiến
Một workflow GitHub Actions chạy hằng tháng (cron: "0 3 1 * *") dựng lại nhánh upstream và mở pull request nếu main chưa chứa nó. Có hai tính chất giúp workflow chạy không cần người trông.
filter-repo cho kết quả tất định. Viết lại cùng một lịch sử upstream thì ra cùng các commit id, nên dựng lại nhánh sẽ tái tạo đúng các commit cũ và thêm commit mới lên trên. Script kiểm tra điều đó trước khi dời nhánh:
if ! git -C "$ROOT" merge-base --is-ancestor upstream "$NEW"; then
echo "Refusing to update: $NEW does not descend from the current upstream branch." >&2
exit 1
fi
Nếu một ngày Yoast viết lại lịch sử, hoặc filter-repo đổi cách xuất kết quả, lần đồng bộ sẽ dừng, thay vì tạo ra một upstream thứ hai không liên quan và kéo lại các conflict cả file.
PR đồng bộ luôn merge bằng merge commit, không bao giờ squash. Squash merge chép các thay đổi nhưng bỏ mất liên kết tới các commit upstream, nên lần đồng bộ sau sẽ tìm về merge base cũ và giải lại mọi conflict. Quy tắc này được ghi trong CONTRIBUTING.md và trong nội dung của mọi PR đồng bộ, vì đây là thiết lập duy nhất làm hỏng cả hệ thống mà không báo gì.
Các quy tắc giữ cho lần đồng bộ nào cũng nhẹ
Merge ba chiều chỉ giúp được khi thay đổi của bản fork và của upstream nằm ở những dòng khác nhau. Ba quy tắc trong CONTRIBUTING.md lo phần lớn việc đó:
- Không format lại file của upstream. Repo có quy tắc lint riêng, nhưng file từ Yoast được giữ nguyên từng byte trừ khi logic của nó buộc phải đổi. Một lượt format sẽ biến mọi chỉnh sửa upstream về sau thành conflict.
- Không sửa import trong file upstream. File của Yoast import package theo tên,
import ... from "yoastseo". Thay vì viết lại các import đó, Babel và TypeScript trỏ cái tên đó về mã nguồn local:
// babel.config.js
["module-resolver", { alias: { "@seo-analyzer": "./src", "yoastseo": "./src" } }]
- Code mới nằm trong file mới. Các tiêu chí mới, mô hình tính điểm và component React hiển thị kết quả nằm trong những file Yoast không có, nên không bao giờ conflict.
Những gì mình bỏ đi là mặt kia của cùng một ý. Web Worker API và các phần riêng cho WordPress bị xoá hẳn chứ không sửa, vì file đã xoá chỉ conflict khi upstream sửa nó, và conflict đó lần nào cũng giải theo một cách.
Phụ thuộc vào một thư viện UI đã bị deprecate
Thông báo kết quả được render bằng component React xây trên Shopify Polaris, nên đặt vào trang quản trị của một app Shopify là khớp giao diện mà không phải style thêm. Từ đó tới nay, Shopify đã deprecate Polaris React để chuyển sang Polaris web components. Package vẫn chạy, với peer dependency @shopify/polaris@^13, nhưng người dùng mới nên biết rằng nó buộc họ vào một thư viện sẽ không có tính năng mới.
Kế hoạch trong phần roadmap của README là để các tiêu chí trả về dữ liệu thuần (trạng thái, thông báo, link) và chuyển phần render bằng Polaris thành một lớp tuỳ chọn. Đó cũng là hướng thân thiện nhất với bản fork: bề mặt công khai càng ít phụ thuộc vào một bộ UI, càng ít lý do phải chạm vào file dùng chung.
Cái giá còn lại là dung lượng. Bản build CommonJS chứa mọi ngôn ngữ, khoảng 1 MB sau khi gzip. Một bản build ES module có entry point riêng cho từng ngôn ngữ sẽ cho phép bundler bỏ những ngôn ngữ app không dùng.
Cách làm này không giải quyết được gì
- Bản fork vẫn trôi dần. Các lần đồng bộ giữ cho phần xử lý ngôn ngữ của upstream luôn mới, nhưng các tiêu chí chấm điểm giờ là code của mình. Một bản sửa Yoast làm cho tiêu chí của họ sẽ không tự tới được tiêu chí đã viết lại.
- Conflict vẫn xảy ra khi upstream sửa một file mà bản fork buộc phải sửa. Các quy tắc làm nó hiếm đi chứ không làm nó biến mất, và mỗi lần như vậy mình giải tay trên một nhánh local.
- Việc soát giấy phép chỉ đúng tại một thời điểm. Bảng code bên thứ ba phản ánh các file ở lần soát gần nhất; lần đồng bộ nào kéo về một file vendor mới thì phải kiểm lại.
Tóm tắt
| Vấn đề | Cách làm |
|---|---|
| Yêu cầu của GPL với bản sửa đổi | GPL-3.0-or-later, NOTICE.md ghi nguồn gốc, điểm fork và các thay đổi, đóng vào package |
| Code đi kèm theo giấy phép khác | Giữ header gốc, bảng đường dẫn và giấy phép trong NOTICE.md |
| Bản fork không có lịch sử chung | Nhánh upstream dựng bằng filter-repo, cộng một merge ours không đổi nội dung tại điểm fork |
| Đồng bộ hằng tháng không người trông | Viết lại tất định, từ chối mọi thứ không phải fast-forward, chỉ merge bằng merge commit |
| Conflict tốn công | File upstream giữ nguyên từng byte, import qua alias, code mới trong file mới |
Nếu bạn đang duy trì một bản fork của dự án lớn hơn và vẫn đồng bộ đều, mình rất muốn được trao đổi xem điều gì giữ cho các lần merge của bạn nhỏ. Email của mình ở trang chủ.