Skip to content

fix(ci): gen-manifests never rewrites a committed version DOWNWARD - #4917

Merged
meshweaver-cloud[bot] merged 2 commits into
mainfrom
fix/4781-never-downgrade-a-committed-version
Sep 19, 2026
Merged

meshweaver-cloud[bot] merged 2 commits into
mainfrom
fix/4781-never-downgrade-a-committed-version

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

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 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

That is the 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.

What changed

  • generate() is now two phases — derive for every module, refuse, then write. Writing as it
    went would leave the earlier modules downgraded and only then refuse.
  • A derived number BELOW the committed one refuses, naming each module, both numbers, and which
    of the two situations this is: git fetch --tags --force <remote>, or "this checkout has NO git
    remote, 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

defect arm : Mod/v1.2.0 tagged, committed 1.2.5, a content change ⇒ derives 1.2.1
             ⇒ exit 1, and the lock still reads 1.2.5
control arm: same fixture, committed 1.2.0, a content change     ⇒ written, FORWARD to 1.2.1

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-test exits 0 on this branch and its verdict line
names the new case.

No satellite is reddened by this

check-resolver-copy.py's COPY_PATH is scripts/resolve-platform.py alone, so a gen-manifests.py
canonical change starts no drift wave. The four repos on centralized-gen-manifests: true fetch this
canonical 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 public type or member leaves src/; the diff is one CI script and a What's
New entry.

🤖 Generated with Claude Code

`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>
Copilot AI lite review requested due to automatic review settings September 19, 2026 19:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Medium severity

Open (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.

Comment thread .github/scripts/gen-manifests.py Outdated
Comment on lines +923 to +927
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.")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions

github-actions Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

265 tests   265 ✅  2m 52s ⏱️
  1 suites    0 💤
  1 files      0 ❌

Results for commit e6785de.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

443 tests   443 ✅  56s ⏱️
  3 suites    0 💤
  3 files      0 ❌

Results for commit e6785de.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 553 tests   1 553 ✅  3m 16s ⏱️
    2 suites      0 💤
    2 files        0 ❌

Results for commit e6785de.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

    3 files      3 suites   5m 38s ⏱️
1 861 tests 1 861 ✅ 0 💤 0 ❌
1 862 runs  1 862 ✅ 0 💤 0 ❌

Results for commit e6785de.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

710 tests   518 ✅  5m 34s ⏱️
  3 suites  192 💤
  3 files      0 ❌

Results for commit e6785de.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    5 files      5 suites   12m 22s ⏱️
3 679 tests 3 677 ✅ 2 💤 0 ❌
3 683 runs  3 681 ✅ 2 💤 0 ❌

Results for commit e6785de.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Test Results

   17 files     17 suites   30m 40s ⏱️
8 511 tests 8 317 ✅ 194 💤 0 ❌
8 516 runs  8 322 ✅ 194 💤 0 ❌

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>
@meshweaver-cloud
meshweaver-cloud Bot added this pull request to the merge queue Sep 19, 2026
Merged via the queue into main with commit 6a4b34f Sep 19, 2026
37 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gen-manifests.py can still silently DOWNGRADE a committed version when the checkout has no remote

2 participants