Forking a library is one command. Keeping the fork honest is the work: its license obligations, where it came from, and a way to keep taking the original's fixes. I forked Yoast's GPL-licensed yoastseo and published it on npm, and four parts of that needed actual decisions. They are the license and notices, a history link to upstream that makes merges three-way, a monthly sync, and rules that keep those syncs cheap.
The fork is seo-analyzer, published as @vuminhhoa/seo-analyzer in October 2026. It keeps Yoast's language processing and replaces the assessments and scoring; reviewing those rules against Google's documentation is a separate post. This one is only about being a fork.
What the GPL asks of a fork you publish
yoastseo is GPL-licensed, so the fork is too: the package is GPL-3.0-or-later. Picking a more permissive license was never an option, because a modified version of GPL code can only be distributed under the GPL. Section 5 of GPLv3 adds two concrete requirements for modified versions: the work must carry prominent notices stating that you modified it, with a relevant date, and notices stating that it is released under the GPL.
In practice that became one file, NOTICE.md, shipped inside the npm package next to LICENSE:
"files": ["build/", "types/", "LICENSE", "NOTICE.md"]
It says what the project is a modified version of, who holds copyright on the original, which upstream commit it was forked from and when it last synced, and a short list of the main changes. The list matters more than it looks: "a fork of X" tells a reader nothing about what is different, and the license asks you to say that something is.
The obligation also travels to anyone who uses the package. An app that bundles a GPL library and is distributed is, per the GPL FAQ, a combined work covered by the GPL. I am not a lawyer, and this is not advice. It is still the first thing I would want to know before adding the dependency to a closed-source app, so the README states the license in its first lines rather than at the bottom.
Third-party code keeps its own license
Yoast's package itself bundles code under other licenses, and a fork inherits all of it. Going through the files with license headers turned up three groups, now listed in a table in NOTICE.md:
| Path | Origin | License |
|---|---|---|
src/vendor/turkishStemmer.js |
Snowball JavaScript | BSD-3-Clause |
languages/*/helpers/internal/stem.js |
Snowball, Porter and other stemmers | BSD or MIT, per file |
languages/hu/config/internal/* |
Word lists from a Common Crawl corpus | CC BY-SA 4.0 |
Those files keep their original headers untouched. The table exists so that someone auditing dependencies does not have to grep 20 language folders to find out that a word list carries a share-alike clause.
Recording the fork point so merges are three-way
The repository did not start as a GitHub fork. Its first commit was a copy of the source, which meant git had no idea that src/ and Yoast's packages/yoastseo/src were related. Every future sync would compare two unrelated trees, and each file both sides had touched would conflict in full.
The fix has two parts. First, a branch called upstream that holds only the history of Yoast's package, with the paths rewritten to match this repository, built by git-filter-repo:
git -C "$WORK/yoast" filter-repo --force --quiet \
--path packages/yoastseo/src/ \
--path-rename packages/yoastseo/src/:src/
Second, a merge that records where the fork came from without changing any file. That is what the ours merge strategy does: the result keeps the current tree and gains the upstream commit d30b500 (13 January 2025) as a second parent.
git merge -s ours d30b500 -m "Record fork point from upstream yoastseo (d30b500, 2025-01-13)"
git diff between the merge and its first parent is empty, which is the point. From then on, git merge upstream finds a real merge base, and only lines changed on both sides since d30b500 can conflict. The first sync, up to upstream afe80002 (13 October 2026), touched 210 files and went through as an ordinary pull request.
A sync that only moves forward
A monthly GitHub Actions workflow (cron: "0 3 1 * *") rebuilds the upstream branch and opens a pull request if main does not contain it yet. Two properties keep it safe to run unattended.
filter-repo is deterministic. Rewriting the same upstream history gives the same commit ids, so a rebuild of the branch reproduces the old commits and adds new ones on top. The script checks that before moving the branch:
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
If Yoast ever rewrote its history, or filter-repo changed its output, the sync stops instead of creating a second, unrelated upstream that would bring back the full-file conflicts.
Sync pull requests are merged with a merge commit, never squashed. A squash merge copies the changes but drops the link to the upstream commits, so the next sync would find the old merge base and redo every conflict. The rule is written in CONTRIBUTING.md and in the body of every sync PR, because it is the one setting that silently breaks the setup.
Rules that keep syncs cheap
A three-way merge only helps if the fork's changes and upstream's changes stay in different lines. Three rules in CONTRIBUTING.md do most of that work:
- Do not reformat upstream files. The repository has its own lint rules, but files from Yoast are left byte-identical unless their logic has to change. A formatting pass would turn every later upstream edit into a conflict.
- Leave upstream imports alone. Yoast's files import the package by name,
import ... from "yoastseo". Instead of rewriting those imports, Babel and TypeScript resolve the name to the local source:
// babel.config.js
["module-resolver", { alias: { "@seo-analyzer": "./src", "yoastseo": "./src" } }]
- Put new code in new files. The new assessments, the scoring model and the React result components live in files Yoast does not have, so they can never conflict.
What I removed is the other side of the same idea. The Web Worker API and the WordPress-specific parts were deleted outright rather than edited, because a deleted file only conflicts when upstream changes it, and that conflict is always resolved the same way.
Depending on a deprecated UI library
The result messages render as React components built on Shopify Polaris, so they fit into the admin of a Shopify app without extra styling. Since then, Shopify has deprecated Polaris React in favour of Polaris web components. The package still works, with @shopify/polaris@^13 as a peer dependency, but a new user should know that it pins them to a library that will not get new features.
The plan in the README's roadmap is to make assessments return plain data (status, message, link) and move the Polaris rendering into an optional layer. That is also the most fork-friendly direction: the less of the public surface depends on one UI kit, the fewer reasons there are to touch shared files.
The other known cost is size. The CommonJS build includes every language, about 1 MB gzipped. An ES module build with an entry point per language would let bundlers drop the ones an app never uses.
What this does not solve
- The fork still drifts. Syncs keep upstream's language processing current, but the assessments are now my own code. A fix Yoast makes to one of its assessments does not reach the rewritten one.
- Conflicts still happen when upstream changes a file the fork had to change. The rules make them rare, not impossible, and they are resolved by hand on a local branch.
- License review is a snapshot. The third-party table reflects the files as of the last review; a sync that brings in a new vendored file needs the same check again.
Summary
| Problem | What I did |
|---|---|
| GPL requirements for a modified version | GPL-3.0-or-later, NOTICE.md with origin, fork point and changes, shipped in the package |
| Bundled code under other licenses | Original headers kept, a table of paths and licenses in NOTICE.md |
| Fork with no shared history | upstream branch built by filter-repo, plus a no-change ours merge at the fork point |
| Unattended monthly sync | Deterministic rewrite, refuse anything that is not a fast-forward, merge commits only |
| Expensive conflicts | Upstream files byte-identical, imports aliased, new code in new files |
If you maintain a fork of a larger project and keep syncing it, I'd love to compare notes on what kept your merges small. My email is on the home page.