fix(ci): gen-manifests never rewrites a committed version DOWNWARD - #4917
Conversation
`generate()`'s only direction guard (`semver_stale`) applied to a manifest that was OTHERWISE up to
date, so a tagless checkout left an unchanged module alone. It did not cover the case that actually
corrupts: a CONTENT change. Then `files`/`moduleVersion` move, the lock MUST be rewritten, and
`version` went out as whatever the incomplete tag set derived.
Measured on a fixture with NO git remote — the case `derivation_inputs` deliberately admits, because
nothing can have been published elsewhere when there is nowhere else:
committed version: 1.2.1
tag set: no git remote configured — the local tags are the whole published set
✎ Mod: v1.2.0, moduleVersion 496e… (was v1.2.1 / 1d46…)
✓ 1 module(s): 1 rewritten, 0 already current
exit=0 version after=1.2.0
A silent downgrade at exit 0 — the exact defect `--check-versions`' own hint warns about, performed
by the generator. A published version describes exactly one tree forever, so re-issuing a lower
number hands an already-published number to a different tree.
- `generate()` is now TWO PHASES: derive for every module first, refuse, then write. Writing as we
went would leave the earlier modules downgraded and only then refuse.
- a derived number BELOW the committed one is treated as what it is — evidence the derivation did
not see every release — and refuses with exit 1, naming each module, both numbers, and which of
the two situations it is in (`git fetch --tags --force <remote>`, or "this checkout has NO remote,
run the generator where the tags are"). It says so for a hand-edited number too, and declines to
correct that one itself, because from here the two are indistinguishable.
Control, self-test case 6, both arms, on a no-remote fixture:
- defect arm: `Mod/v1.2.0` tagged, committed `1.2.5`, a content change ⇒ derives 1.2.1 ⇒ exit 1,
the lock still reads 1.2.5.
- control arm: same fixture, committed `1.2.0`, a content change ⇒ written, FORWARD to 1.2.1.
Without it the refusal could be unconditional and prove nothing.
The defect arm was confirmed against `origin/main`'s copy of the script: `v1.2.1` → `v1.2.0`, exit 0.
`--self-test` exits 0 on this branch; the verdict line names the new case.
No satellite is reddened by this: `check-resolver-copy.py`'s `COPY_PATH` is `resolve-platform.py`
alone, and the four repos on `centralized-gen-manifests: true` fetch this canonical live, so they
pick the fix up with no re-copy. Education and Plugins still run their own vintages — that is #4777.
Fixes #4781
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Three moderate review findings remain in gen-manifests.py.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
What changed in this PR
This PR prevents gen-manifests.py from silently downgrading committed module versions and adds regression documentation.
Changes:
- Separates version derivation from manifest writes and refuses downgrades.
- Adds regression self-tests and remediation guidance.
- Documents the fix in What’s New.
| File | Summary | Final review findings |
|---|---|---|
src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-19-a-version-number-could-be-rewritten-backwards.md |
Documents the user-facing fix. | None. |
.github/scripts/gen-manifests.py |
Implements downgrade protection and self-tests. | Moderate (3 votes): make remote/no-remote guidance conditional. Moderate (1 vote): cover multi-module atomicity. Moderate (1 vote): assert exit code 1. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| print(" A version already committed above everything derivable means the releases this " | ||
| "derivation reads are MISSING, not that the committed number is wrong.") | ||
| print(f" Fix: git fetch --tags --force {remote_for_hint}" if remote_for_hint else | ||
| " Fix: this checkout has NO git remote, so a release published elsewhere is " | ||
| "invisible to it — run the generator where the tags are.") |
There was a problem hiding this comment.
Correct, and the contradiction is with check_versions, which already draws exactly this distinction on the same comparison — so the refusal was telling the operator the opposite of what the code one screen up says. Fixed: the two arms now have their own remedies, and neither can speak the other's.
With a remote (derivation_inputs has already proven the tag set and the trunk baseline current — it refuses before reaching this comparison if it could not):
✗ refusing to rewrite a committed version DOWNWARD — a published version describes exactly one tree forever:
- Mod: manifest.lock records version '1.0.9', and this checkout derives '1.0.2' — LOWER. Refusing to rewrite it down.
The tag set and the trunk baseline were verified against 'origin' before this comparison, so NOTHING is
missing — the committed number is one neither the published tags nor the trunk's committed manifest
justify. A hand-edited or orphaned version, not absent evidence.
Fix: correct the `version` in that manifest.lock deliberately (the derived number above is the one CI
computes), then re-run. Re-fetching tags cannot change this result.
With no remote (nothing was verified, and a release published elsewhere is invisible by construction):
This checkout has NO git remote, so nothing could be verified and a release published elsewhere is
invisible to it by construction — the committed number being higher is exactly the evidence that such
a release exists.
Fix: run the generator where the published tags are. Do NOT correct the committed version from here;
from inside this checkout a real release and a hand-edited number are indistinguishable.
Note the remedies are not merely differently worded, they are opposite: one says correct the number, the other says do not correct it from here. That is why the reviewed message was worse than vague.
Self-test case 7 covers the remote arm on its own fixture — origin with Mod/v1.0.0 and Mod/v1.0.1 pushed, a committed 1.0.9, a content change ⇒ derives 1.0.2 — and both arms now assert on the words, each checking that it does NOT contain the other's remedy:
self-test: no-remote arm — must say "NO git remote"; must NOT say "Re-fetching tags cannot change this result"
self-test: remote arm — must say "NOTHING is missing"
and "hand-edited or orphaned"; must NOT say "run the generator where the published tags are"
Both also assert the committed version is still 1.0.9 / 1.2.5 afterwards, because refusing means refusing. --self-test exits 0.
The capture that makes those assertions possible is a small run_generate helper inside the self-test that redirects stdout and re-prints it, so the log a reader sees is unchanged.
Test Results (shard 0)265 tests 265 ✅ 2m 52s ⏱️ Results for commit e6785de. ♻️ This comment has been updated with latest results. |
Test Results (shard 3)443 tests 443 ✅ 56s ⏱️ Results for commit e6785de. ♻️ This comment has been updated with latest results. |
Test Results (shard 1)1 553 tests 1 553 ✅ 3m 16s ⏱️ Results for commit e6785de. ♻️ This comment has been updated with latest results. |
Test Results (shard 4) 3 files 3 suites 5m 38s ⏱️ Results for commit e6785de. ♻️ This comment has been updated with latest results. |
Test Results (shard 2)710 tests 518 ✅ 5m 34s ⏱️ Results for commit e6785de. ♻️ This comment has been updated with latest results. |
Test Results (shard 5) 5 files 5 suites 12m 22s ⏱️ Results for commit e6785de. ♻️ This comment has been updated with latest results. |
Test Results 17 files 17 suites 30m 40s ⏱️ Results for commit e6785de. ♻️ This comment has been updated with latest results. |
…mote Copilot is right, and the contradiction is with `check_versions`, which already draws exactly this distinction on the same comparison — so the refusal was telling the operator the opposite of what the code one screen up says. With a remote, `derivation_inputs` has ALREADY proven the tag set and the trunk baseline current (it refuses before reaching the comparison if it could not), so nothing is missing: the committed number is one no witness justifies — hand-edited or orphaned — and re-fetching cannot change the result. With NO remote nothing was verified, and the committed number being higher IS the evidence that a release exists elsewhere. The remedies are not differently worded, they are opposite: one says correct the number, the other says do NOT correct it from here. - the refusal branches on `_remote_name(root)` and each arm states its own reasoning and remedy - self-test case 7 adds the remote arm on its own fixture: origin with `Mod/v1.0.0` + `Mod/v1.0.1` pushed, a committed `1.0.9`, a content change ⇒ derives `1.0.2` ⇒ exit 1, lock still `1.0.9` - both arms now assert on the WORDS, and each asserts it does NOT contain the other's remedy — the no-remote arm must not say "Re-fetching tags cannot change this result", the remote arm must not say "run the generator where the published tags are" - a small `run_generate` helper inside the self-test captures stdout and re-prints it, so what a reader sees in the log is unchanged `--self-test` exits 0; the verdict line names the split. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

The residual #4779 left, measured on the case its guard does not cover
generate()'s direction guard (semver_stale) only applied to a manifest that was otherwise up todate, so a tagless checkout left an unchanged module alone. It did not cover the case that actually
corrupts: a content change. Then
files/moduleVersionmove, the lock must be rewritten, andversionwent out as whatever the incomplete tag set derived.Measured on a fixture with no git remote — the case
derivation_inputsdeliberately admits,because nothing can have been published elsewhere when there is nowhere else:
That is the defect
--check-versions' own hint warns about, performed by the generator. A publishedversion describes exactly one tree forever, so re-issuing a lower number hands an already-published
number to a different tree.
What changed
generate()is now two phases — derive for every module, refuse, then write. Writing as itwent would leave the earlier modules downgraded and only then refuse.
of the two situations this is:
git fetch --tags --force <remote>, or "this checkout has NO gitremote, so a release published elsewhere is invisible to it — run the generator where the tags
are." It also says what to do if the committed number really is hand-edited or orphaned, and
declines to correct that one itself — from inside the checkout the two are indistinguishable, which
is exactly why the old code could not be trusted to choose.
This is the shape #4781 asked for ("treating a committed version that is AHEAD of the derived one as
evidence that tags are missing"), and it reads correctly for the tag-incomplete case as well as the
no-remote one.
Control — self-test case 6, both arms
The control arm is not ceremony: without it the case would pass on a generator that refuses
everything. The defect arm was confirmed against
origin/main's copy of the script —v1.2.1→v1.2.0, exit 0,VERDICT: DOWNGRADED.python3 .github/scripts/gen-manifests.py --self-testexits 0 on this branch and its verdict linenames the new case.
No satellite is reddened by this
check-resolver-copy.py'sCOPY_PATHisscripts/resolve-platform.pyalone, so agen-manifests.pycanonical change starts no drift wave. The four repos on
centralized-gen-manifests: truefetch thiscanonical live and pick the fix up with no re-copy. Education and Plugins still run their own
vintages — that is #4777, and this fix will have to travel with whichever port lands there.
Fixes #4781
Pairs-with: none — no
publictype or member leavessrc/; the diff is one CI script and a What'sNew entry.
🤖 Generated with Claude Code