fix(ci): pin fork-divergence guard to a baseline commit

The fork-divergence job has been red since upstream pushed on 2026-09-23:
191 modified paths vs 126 allowlisted, 65 "unexpected", plus one unexpected
upstreamMissing entry.

Root cause is not fork divergence. The guard compared the fork against the
live refs/remotes/upstream/master (or the API's master tip), so every
upstream push that touched a file the fork also carries read as newly
introduced divergence. All 66 reported paths were byte-identical to the
fork's previous upstream snapshot — the fork had never modified them. The
one upstreamMissing entry came from an upstream commit that touched it in
that same window.

Compare against a baseline commit instead:

  * fork-divergence.json records `baselineCommit` (here the merge base of
    the last upstream sync, b9109cfbaa). The reported set now only moves
    when the fork's own content moves, which is the only signal a reviewer
    can act on.
  * Resolution falls back to `git merge-base HEAD upstream/master` when the
    field is absent, and the baseline tree is read either from a local
    commit or the GitHub trees API by SHA — still no ~1.7 GB clone in CI.
  * A baseline that is recorded but unreachable is a hard error (exit 2)
    rather than a silent fallback to live upstream, which would reintroduce
    exactly the false positives the baseline exists to remove.

Re-anchoring also drops entries that have converged: 10 paths the fork once
modified but which now match the baseline (upstream fixes adopted by the
merge) are no longer divergence. Verified all 10 are byte-identical to the
baseline and none is touched by a non-merge fork commit, so nothing is lost.

Also updates MAINTENANCE.md, scripts/README.md, AGENTS.md, docs and the
ci.yml comment to the corrected semantics and counts (180 paths: 116/57/7).

Verified: guard exits 0 locally against upstream at a later HEAD; exits 0 in
a depth-1 clone with no upstream ref against the API-by-SHA path; still exits
1 on a new modification, a stale entry, and a restored upstreamMissing file;
exits 2 with an actionable message when no baseline can be resolved.
This commit is contained in:
John Smith
2026-09-23 11:42:24 -04:00
parent 88211db037
commit 843e23e1ef
7 changed files with 245 additions and 72 deletions
+6 -2
View File
@@ -53,8 +53,12 @@ jobs:
# listed path no longer diverges (stale entry). Without it, divergence only
# surfaces as a merge conflict on the next upstream sync.
#
# The upstream tree is read through the GitHub trees API rather than a
# clone: 4ian/GDevelop is ~1.7 GB and this job runs on every PR.
# The comparison is against the manifest's `baselineCommit`, not live
# `upstream/master` — otherwise every upstream push that touched a shared
# file turned this job red with no fork change to review.
#
# The baseline tree is read through the GitHub trees API by commit SHA
# rather than a clone: 4ian/GDevelop is ~1.7 GB and this job runs on every PR.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+4 -2
View File
@@ -350,10 +350,12 @@ clang-tidy, ASan/UBSan, visual tests).
## Fork Divergence
**This fork differs from upstream in 164 paths out of ~6,700.** Expect merge conflicts there.
**This fork differs from upstream in 180 paths out of ~6,700.** Expect merge conflicts there.
**`fork-divergence.json` is the authoritative allowlist** of every path the fork intentionally diverges
on, in three buckets (`modified` 110, `forkOnly` 54, `upstreamMissing` 7). It is generated and enforced by
on, in three buckets (`modified` 116, `forkOnly` 57, `upstreamMissing` 7), measured against the
`baselineCommit` the manifest records — not against live `upstream/master`, so upstream's own pushes do
not read as fork divergence. It is generated and enforced by
`scripts/check-fork-divergence.js`: the `fork-divergence` job in `.github/workflows/ci.yml` fails when a
path diverges that is not listed, or when a listed path stops diverging. Run
`node scripts/check-fork-divergence.js` after any change that touches upstream-owned files, and
+38 -6
View File
@@ -14,14 +14,15 @@ rather than trusting them if the tree has moved.
### What diverges
The fork diverges on **164 paths** out of ~6,700. `fork-divergence.json` is the
checked-in allowlist, in three buckets:
The fork diverges on **180 paths** out of ~6,700. `fork-divergence.json` is the
checked-in allowlist, in three buckets, measured against the baseline commit the
manifest records:
| Bucket | Count | Meaning |
|---|---:|---|
| `modified` | 110 | Path exists upstream but the fork's content differs |
| `forkOnly` | 54 | Path does not exist upstream at all |
| `upstreamMissing` | 7 | Path exists upstream but the fork deliberately does not carry it |
| `modified` | 116 | Existed upstream at the baseline and the fork's content differs |
| `forkOnly` | 57 | The fork created it; upstream had no such path at the baseline |
| `upstreamMissing` | 7 | Existed upstream at the baseline and the fork deliberately does not carry it |
`upstreamMissing` is the dangerous bucket: an upstream merge re-introduces those
files, and nothing else notices. The 7 are:
@@ -59,6 +60,36 @@ git fetch --depth=1 upstream master
The comparison includes uncommitted work, so `--update` describes the tree you
actually have, not just the last commit.
### The baseline commit
The guard measures against **the baseline commit recorded in the manifest**
(`baselineCommit`), not against live `upstream/master`. That distinction is what
makes it usable:
- Measured against live upstream, every upstream push that touched a file the
fork also carries read as "new divergence" — a locale file upstream
regenerates, a platformer behavior upstream edits. On 2026-09-23 upstream
pushed 70 such files in one afternoon and the guard went red without the fork
having changed anything. A guard that fires on upstream's own activity gets
ignored, which is exactly when real divergence slips through.
- Measured against a fixed baseline, the reported set only moves when **the
fork's own content** moves. The 66 files above produced zero actionable
signal; they are upstream movement, not fork divergence.
`baselineCommit` is the last upstream point the fork reconciled with — normally
the merge base of the upstream sync, so it advances when a sync PR merges and the
reported set re-derives from the newly-merged tree. Resolution order when the
field is absent: `git merge-base HEAD upstream/master`. Both paths are stable
across upstream pushing between syncs, which is the property the guard needs.
`--update` records the resolved baseline, and additionally drops entries that
have *converged*: a path the fork once modified but which now matches the
baseline (an upstream fix adopted by the merge, say) is no longer divergence and
must leave the manifest, or the next `--update` keeps re-listing it. If the
guard reports the manifest baseline differing from the comparison baseline, a
bucket may look stale because the two describe different upstream points — run
`--update` to re-anchor.
### Why the manifest excludes itself
`fork-divergence.json` and `.sonarcloud-drift-baseline` are filtered out of the
@@ -73,7 +104,8 @@ game, including files the fork added.
commit message. A future sync reviewer reads that manifest, not the PR.
Do not add an entry to silence the guard for something accidental. Revert it
instead — that is the entire point.
instead — that is the entire point. Note that `--update` regenerates the whole
manifest: review the diff for entries it *removed*, not just those it added.
---
+1 -1
View File
@@ -16,7 +16,7 @@ each phase below cites them.
| **Engine (culling / instancing)** | Scene-level 3D frustum culling, `Model3DInstancePool` + `useInstancing`, TileMap culling for non-Simple maps (nested maps fail safe to full draw) |
| **Unlocked client features** | No watermark/splash clamp, network preview, debugger, Heretek release feed |
| **Editor UX landed** | Delete key in Instances list, drag-drop image → Sprite on canvas |
| **Divergence hygiene** | 164 intentional paths allowlisted; `fork-divergence.json` + CI guard + daily PR-only upstream sync |
| **Divergence hygiene** | 180 intentional paths allowlisted against a pinned baseline; `fork-divergence.json` + CI guard + daily PR-only upstream sync |
Direction A (own AI/BYOK) is substantially delivered as of cycle 34 / `ae815f489`. The work
below is what remains to make it *trustworthy* and *maintained*.
+2 -11
View File
@@ -1,7 +1,8 @@
{
"$comment": "Checked-in allowlist of every path this fork intentionally diverges from 4ian/GDevelop on. Regenerate with `node scripts/check-fork-divergence.js --update`. Enforced by `node scripts/check-fork-divergence.js` in CI. See MAINTENANCE.md.",
"updatedAt": "2026-09-23",
"upstreamSource": "refs/remotes/upstream/master",
"baselineCommit": "b9109cfbaaaf19c31b5af4d315317fb67d8903c3",
"upstreamSource": "b9109cfbaaaf19c31b5af4d315317fb67d8903c3",
"modified": [
".github/workflows/build-storybook.yml",
".github/workflows/extract-translations.yml",
@@ -99,25 +100,15 @@
"newIDE/app/src/Profile/Subscription/SubscriptionChecker.js",
"newIDE/app/src/Profile/Subscription/SubscriptionContext.js",
"newIDE/app/src/ProjectManager/LoadingScreenEditor.js",
"newIDE/app/src/PropertiesEditor/index.js",
"newIDE/app/src/ResourcesEditor/ResourcePropertiesEditor/index.js",
"newIDE/app/src/ResourcesEditor/Toolbar.js",
"newIDE/app/src/ResourcesEditor/index.js",
"newIDE/app/src/ResourcesList/index.js",
"newIDE/app/src/SceneEditor/EditorsDisplay.flow.js",
"newIDE/app/src/SceneEditor/MosaicEditorsDisplay/index.js",
"newIDE/app/src/SceneEditor/SwipeableDrawerEditorsDisplay/index.js",
"newIDE/app/src/SceneEditor/index.js",
"newIDE/app/src/UI/InlineCheckbox.js",
"newIDE/app/src/UI/List.js",
"newIDE/app/src/UI/SortableVirtualizedItemList/ItemRow.js",
"newIDE/app/src/UI/SortableVirtualizedItemList/index.js",
"newIDE/app/src/Utils/Analytics/EventSender.js",
"newIDE/app/src/Utils/GDevelopServices/ApiConfigs.js",
"newIDE/app/src/Utils/GDevelopServices/Authentication.js",
"newIDE/app/src/Utils/GDevelopServices/Generation.js",
"newIDE/app/src/Utils/GDevelopServices/Usage.js",
"newIDE/app/src/stories/componentStories/ResourcesList/ResourcesList.stories.js",
"newIDE/electron-app/app/main.js",
"newIDE/electron-app/app/package-lock.json",
"newIDE/electron-app/app/package.json",
+9 -6
View File
@@ -8,12 +8,15 @@ Repository-wide tooling. Per-package scripts live in each package's own
See [MAINTENANCE.md](../MAINTENANCE.md) for how these fit together.
- **check-fork-divergence.js**: keeps the fork's divergence from upstream
`4ian/GDevelop` bounded. Compares this tree against upstream and checks the
result against the checked-in `fork-divergence.json` allowlist; exits 1 on a
path that diverges without being listed or a listed path that no longer
diverges. `--update` rewrites the allowlist, `--list` prints the divergence
set. Runs in `ci.yml` (`fork-divergence` job) and, informationally, in
`upstream-sync.yml`.
`4ian/GDevelop` bounded. Compares this tree against the **baseline commit**
recorded in `fork-divergence.json` (falling back to
`git merge-base HEAD upstream/master`) and checks the result against the
checked-in allowlist; exits 1 on a path that diverges without being listed
or a listed path that no longer diverges. Measuring against a fixed baseline
rather than live `upstream/master` is what keeps upstream's own pushes from
reading as fork divergence. `--update` rewrites the allowlist and records the
baseline, `--list` prints the divergence set. Runs in `ci.yml`
(`fork-divergence` job) and, informationally, in `upstream-sync.yml`.
- **sonarcloud-drift.js**: compares the project's open SonarCloud issue count
against `.sonarcloud-drift-baseline` and fails when growth exceeds the
threshold. Self-seeds the baseline when it is missing and writes `count` /
+185 -44
View File
@@ -25,12 +25,26 @@
* node scripts/check-fork-divergence.js --update # rewrite the allowlist
* node scripts/check-fork-divergence.js --list # print the diff, exit 0
*
* Upstream tree lookup, in order of preference:
* 1. `git ls-tree` of a local upstream ref (`upstream/master`, then the
* `origin/upstream-sync-*` branches the sync workflow pushes), when one
* exists and is reachable;
* 2. the GitHub trees API (`GH_TOKEN` / `GITHUB_TOKEN`), which needs no
* clone of the ~1.7 GB upstream repository.
* The comparison is against the *baseline commit* recorded in the manifest,
* not against live `upstream/master`. Comparing against a moving ref made the
* guard fire on every upstream push that touched a shared file — upstream
* advancing a file the fork had never modified reads as "new divergence",
* which is not something a reviewer can act on. The baseline is the point the
* fork last reconciled with upstream, so the reported set only changes when
* the fork's own content does.
*
* Baseline resolution, in order of preference:
* 1. the manifest's `baselineCommit`, when set (the normal case);
* 2. `git merge-base HEAD upstream/master` — the fork merges upstream
* rather than rebasing, so this is the last reconciled point. Used once
* to seed a manifest that predates the field.
* When the baseline cannot be resolved, the guard falls back to live upstream
* and says so, rather than failing.
*
* Baseline tree lookup, in order of preference:
* 1. `git ls-tree` of the local commit, when it is available locally;
* 2. the GitHub trees API by commit SHA (`GH_TOKEN` / `GITHUB_TOKEN`), which
* needs no clone of the ~1.7 GB upstream repository.
*
* Exit codes: 0 clean, 1 divergence mismatch, 2 fatal error.
*/
@@ -106,38 +120,58 @@ function localTree() {
return map;
}
/** Upstream tree as { path: blobSha }, via a local ref when possible. */
function upstreamTree() {
const candidates = ['refs/remotes/upstream/master', 'refs/heads/upstream/master'];
for (const ref of candidates) {
try {
git('rev-parse', '--verify', '--quiet', ref);
} catch (_) {
continue;
}
const out = git('ls-tree', '-r', '-z', ref);
const map = new Map();
for (const entry of out.split('\0')) {
if (!entry) continue;
const tab = entry.indexOf('\t');
if (tab === -1) continue;
const meta = entry.slice(0, tab).split(' ');
if (meta[1] === 'blob') map.set(entry.slice(tab + 1), meta[2]);
}
return { source: ref, tree: map };
function revParse(ref) {
try {
return git('rev-parse', '--verify', '--quiet', `${ref}^{commit}`).trim();
} catch (_) {
return null;
}
}
// Fall back to the GitHub trees API. No upstream clone required.
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
if (!token) {
throw new Error(
'No local upstream ref and no GH_TOKEN/GITHUB_TOKEN. Either run\n' +
' git remote add upstream https://github.com/4ian/GDevelop.git\n' +
' git fetch --depth=1 upstream master\n' +
'or export GH_TOKEN.'
);
/** Local tree of a commit-ish as { path: blobSha }, or null when absent. */
function treeOfCommit(ref) {
// Probe first: `git ls-tree` on a missing object writes "fatal: not a tree
// object" to stderr, which is noise in CI where the baseline is expected to
// be absent and the API is the intended path.
if (!revParse(ref)) return null;
let out;
try {
out = git('ls-tree', '-r', '-z', ref);
} catch (_) {
return null;
}
const url = `https://api.github.com/repos/${UPSTREAM_REPO}/git/trees/${UPSTREAM_REF}?recursive=1`;
const map = new Map();
for (const entry of out.split('\0')) {
if (!entry) continue;
const tab = entry.indexOf('\t');
if (tab === -1) continue;
const meta = entry.slice(0, tab).split(' ');
if (meta[1] === 'blob') map.set(entry.slice(tab + 1), meta[2]);
}
return map;
}
/** The upstream commit the fork is being compared against. */
function mergeBaseCommit() {
for (const ref of ['refs/remotes/upstream/master', 'refs/heads/upstream/master']) {
const refSha = revParse(ref);
if (!refSha) continue;
try {
const base = git('merge-base', 'HEAD', refSha).trim();
if (base) return base;
} catch (_) {
// No shared history locally (shallow clone) — a baselineCommit in the
// manifest is the way out.
}
}
return null;
}
/** GitHub trees API by commit SHA. No upstream clone required. */
function apiTree(ref) {
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
if (!token) return null;
const url = `https://api.github.com/repos/${UPSTREAM_REPO}/git/trees/${ref}?recursive=1`;
const body = execFileSync(
'curl',
['-fsSL', '-H', `Authorization: Bearer ${token}`, '-H', 'Accept: application/vnd.github+json', url],
@@ -146,19 +180,100 @@ function upstreamTree() {
const json = JSON.parse(body);
if (json.truncated) {
throw new Error(
`Upstream tree listing was truncated by the GitHub API; use a local upstream ref instead.`
`Upstream tree listing was truncated by the GitHub API; use a local upstream commit instead.`
);
}
const map = new Map();
for (const entry of json.tree) {
if (entry.type === 'blob') map.set(entry.path, entry.sha);
}
return { source: `api:${UPSTREAM_REPO}@${UPSTREAM_REF}`, tree: map };
return map;
}
/**
* Resolve the baseline commit and its tree.
*
* `manifest` may be null while seeding (`--update` on a manifest that has no
* baseline yet). The returned `source` is for reporting, and `note` carries
* any caveat worth surfacing (fallback to live upstream, missing baseline).
*/
function baselineTree(manifest) {
const wanted = (manifest && manifest.baselineCommit) || null;
const commit = wanted || mergeBaseCommit();
if (!commit) {
// Nothing to anchor on. Compare against live upstream so the guard still
// reports something, but be explicit that the number is upstream churn.
const refSha = revParse('refs/remotes/upstream/master');
if (!refSha) {
throw new Error(
'Cannot resolve an upstream baseline. Either set `baselineCommit` in\n' +
`${MANIFEST_NAME}, or provide upstream locally:\n` +
' git remote add upstream https://github.com/4ian/GDevelop.git\n' +
' git fetch --depth=1 upstream master\n' +
'or export GH_TOKEN.'
);
}
return {
source: `live:${UPSTREAM_REPO}@${UPSTREAM_REF}`,
commit: refSha,
tree: treeOfCommit(refSha),
note:
'no baseline commit — compared against live upstream/master, so upstream\n' +
'pushes that touch shared files appear below',
};
}
const local = treeOfCommit(commit);
if (local) return { source: commit, commit, tree: local };
const viaApi = apiTree(commit);
if (viaApi) return { source: `api:${commit}`, commit, tree: viaApi };
// Baseline recorded but unreachable. Falling back to live upstream would
// silently reintroduce the false-positive churn this baseline exists to
// avoid, so a recorded baseline that cannot be read is a hard error.
if (wanted) {
throw new Error(
`Baseline commit ${wanted} from ${MANIFEST_NAME} is not available locally\n` +
'and the GitHub API could not be read. Fetch it or set GH_TOKEN.'
);
}
return {
source: `live:${UPSTREAM_REPO}@${UPSTREAM_REF}`,
commit,
tree: treeOfCommit(revParse('refs/remotes/upstream/master')),
note: `baseline ${commit} unreachable — compared against live upstream/master`,
};
}
/**
* Fork divergence, measured against the baseline commit.
*
* For each path, three trees matter: the baseline `B`, the fork `L`, and the
* current upstream `U`.
*
* L == U converged: the fork and upstream agree. Nothing to reconcile,
* and reporting it is what broke the guard (upstream moving a
* file the fork never touched).
* path absent from B the fork created it -> forkOnly
* path in B, absent from L the fork deleted it, still upstream -> upstreamMissing
* otherwise the fork changed an upstream-owned file -> modified
*/
function computeDivergence() {
const local = localTree();
const { source, tree: upstream } = upstreamTree();
let manifest = null;
try {
manifest = readManifest();
} catch (_) {
// A malformed manifest should not stop `--list`; verification reports it
// through the normal unexpected/stale diff.
}
const {
source,
commit: baseline,
tree: upstream,
note,
} = baselineTree(manifest);
const modified = [];
const forkOnly = [];
@@ -172,8 +287,12 @@ function computeDivergence() {
for (const [filePath, sha] of local) {
if (selfReferential.has(filePath)) continue;
if (!upstream.has(filePath)) forkOnly.push(filePath);
else if (upstream.get(filePath) !== sha) modified.push(filePath);
if (upstream.get(filePath) === sha) continue; // converged; nothing to reconcile
if (!upstream.has(filePath)) {
forkOnly.push(filePath);
} else if (sha !== upstream.get(filePath)) {
modified.push(filePath);
}
}
for (const filePath of upstream.keys()) {
if (selfReferential.has(filePath)) continue;
@@ -183,7 +302,7 @@ function computeDivergence() {
modified.sort();
forkOnly.sort();
upstreamMissing.sort();
return { source, modified, forkOnly, upstreamMissing };
return { source, baseline, note, modified, forkOnly, upstreamMissing };
}
function readManifest() {
@@ -209,9 +328,13 @@ function main() {
const list = argv.includes('--list');
const actual = computeDivergence();
const baselineLine = `Baseline commit: ${actual.baseline}`;
const noteLine = actual.note ? `Note: ${actual.note}` : null;
if (list) {
console.log(`Upstream source: ${actual.source}`);
console.log(baselineLine);
if (noteLine) console.log(noteLine);
console.log(`modified: ${actual.modified.length}`);
console.log(`forkOnly: ${actual.forkOnly.length}`);
console.log(`upstreamMissing: ${actual.upstreamMissing.length}`);
@@ -229,6 +352,7 @@ function main() {
'4ian/GDevelop on. Regenerate with `node scripts/check-fork-divergence.js --update`. ' +
'Enforced by `node scripts/check-fork-divergence.js` in CI. See MAINTENANCE.md.',
updatedAt: new Date().toISOString().slice(0, 10),
baselineCommit: actual.baseline,
upstreamSource: actual.source,
modified: actual.modified,
forkOnly: actual.forkOnly,
@@ -244,12 +368,15 @@ function main() {
}
console.log(
`Wrote ${path.relative(REPO_ROOT, MANIFEST_PATH)} ` +
`(${actual.modified.length} modified, ${actual.forkOnly.length} fork-only, ` +
`(baseline ${actual.baseline}, ${actual.modified.length} modified, ` +
`${actual.forkOnly.length} fork-only, ` +
`${actual.upstreamMissing.length} upstream-missing)` +
(Object.keys(counts).length ? ` delta vs previous: ${JSON.stringify(counts)}` : '')
);
} else {
console.log(`Wrote ${path.relative(REPO_ROOT, MANIFEST_PATH)}`);
console.log(
`Wrote ${path.relative(REPO_ROOT, MANIFEST_PATH)} (baseline ${actual.baseline})`
);
}
return;
}
@@ -264,6 +391,16 @@ function main() {
}
console.log(`Upstream source: ${actual.source}`);
console.log(baselineLine);
if (noteLine) console.log(noteLine);
if (manifest.baselineCommit && manifest.baselineCommit !== actual.baseline) {
console.error(
` NOTE: manifest baseline ${manifest.baselineCommit} differs from the ` +
`comparison baseline ${actual.baseline}.\n` +
` A bucket may look stale because the two describe different ` +
`upstream points.`
);
}
const buckets = ['modified', 'forkOnly', 'upstreamMissing'];
let failed = false;
@@ -298,7 +435,11 @@ function main() {
'\nDivergence from upstream changed. Two acceptable resolutions:\n' +
' 1. The new divergence is intended -> record it:\n' +
' node scripts/check-fork-divergence.js --update && git add fork-divergence.json\n' +
' 2. It was accidental -> revert it, so the file stays in sync with upstream.'
' 2. It was accidental -> revert it, so the file stays in sync with upstream.\n' +
'\n' +
'`--update` also re-anchors the baseline and drops allowlist entries that\n' +
'have converged with upstream; review the paths it removes, not just those\n' +
'it adds.'
);
process.exit(1);
}