Recurring "GVL misread" bug in release_project_version: when
Project Information.Version drifts BEHIND the runtime-anchor GVL
(_MCP_PROJECT_VERSION.sVersion), the bump used the stale pi.version
as the resume point and silently regressed the version, often
colliding with an existing v* tag.
Observed twice on the MCPTest2 sandbox:
1. v1.0.4.0 (2026-04-26): bump from on-disk 1.2.0.0 read pi.version
as 1.0.3.0 -> revision -> 1.0.4.0. Tag deleted; recovered as
v1.2.1.0 via manual finish script.
2. v1.1.0.0 collision (2026-04-26): bump from on-disk 1.2.1.0 read
pi.version as 1.0.0.0 -> minor -> 1.1.0.0. Tag already existed,
git tag step failed, release pipeline aborted. Recovered by
two manual minor bumps (1.1.0.0 -> 1.2.0.0 -> 1.3.0.0) and an
amended commit, released as v1.3.0.0.
Root cause: the MCPTest2 v1.2.0.0 and v1.2.1.0 releases were
finished by external (non-MCP) Node scripts that updated the GVL
via inject-once but never wrote pi.version back through the
bump_project_version pathway. So pi.version stayed pinned at
whatever value the LAST true bump_project_version run left it at
(in MCPTest2's case, ~1.0.0.0), while the GVL kept moving forward.
Fix: in the pi-present branch, read both pi.version and the GVL
sVersion, parse both as 4-tuples, and take the max as the resume
point. The max is always safe: both sides only ever move forward
in the normal case, so the higher of the two is by construction
the true latest version. When a drift is detected (pi behind GVL),
emit a WARNING and self-heal pi.version forward to the GVL value
before the bump so the warning doesn't recur on the next call.
The pi-missing branch is unchanged (still falls back to GVL).
Documented inline in the function with the regression scenario for
future maintainers. No new test (the affected logic runs inside
CODESYS's IronPython and doesn't have a unit-test scaffold here);
the inline comment + this commit message are the regression record.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously, when a project had no Project Information node (e.g. one
created from the Standard template via create_project), the bump
flow read pi.version as None, the seed-check fired, and every call
re-seeded to 1.0.0.0 -- subsequent revision/minor/major bumps were
no-ops because the script never saw the actual current version.
Surfaced on MCPTest2 today: bumping revision after editing PLC_PRG
returned '1.0.0.0' instead of '1.0.1.0' because the seed kept firing.
Fix: when Project Information is missing, fall back to reading the
existing _MCP_PROJECT_VERSION.sVersion via the textual_declaration
of the GVL we ourselves maintain. So the source-of-truth chain is:
pi.version (when Project Information exists)
-> falls back to GVL.sVersion (when Project Information missing
but the GVL has been written
by a prior bump)
-> falls back to seed at 1.0.0.0 (true first-run, no GVL yet)
Implementation: read_version_from_gvl(primary_project) walks the
active Application's children for the named GVL and parses the
sVersion := '...' literal out of its textual_declaration with a
4-part regex. Returns None if the GVL doesn't exist OR its decl
doesn't match the expected shape; caller treats None as "no prior
version, seed". Soft-fails on any access exception (the bump is
the primary outcome, this is just resume-from-state).
This is the kind of "every arising problem fixed at the fork, not
worked around in one-offs" hygiene the user called out.
Standard-template projects (those created via create_project) often
don't have a Project Information node at all -- it's added lazily by
the IDE when the user opens Project menu -> Project Information for
the first time. Surfaced when running auto-bump against MCPTest2
(copied from MCPTest, which was created via create_project).
Two changes:
1. Use the documented is_project_info marker (ScriptProjectInfoMarker
per the SP22 stub Stubs/scriptengine/ScriptObject.pyi) to find the
node, instead of name-matching 'Project Information'. Also robust
against localised IDE display names ('Projektinformation' in DE).
Walks up to depth 4 from the root.
2. If still no node found, log a WARNING and SKIP the metadata write
(Project Information.Version), but continue with the GVL
maintenance. The GVL is the runtime source-of-truth anyway -- the
running PLC reads _MCP_PROJECT_VERSION.sVersion, not the .project
metadata. Subsequent bumps after the user adds Project Information
manually (Project menu -> Project Information in the IDE) will
pick up both sides.
Output line is also adjusted -- 'Project Information.Version: (skipped
-- node missing) -> 1.0.0.0' instead of pretending to have updated
something that doesn't exist.
Establishes the runtime-readable version anchor convention. Every
bump (manual or auto) now ALSO ensures the Application has a GVL
named '_MCP_PROJECT_VERSION' with:
{attribute 'qualified_only'}
VAR_GLOBAL CONSTANT
sVersion : STRING := '<X.Y.Z.W>';
END_VAR
Created on first bump; updated in place thereafter. Soft-fails if
the Application object can't be found or create_gvl() raises -- the
primary outcome (Project Information.Version updated and saved) has
already happened by the time GVL maintenance runs, so a GVL hiccup
is logged as a WARNING but doesn't fail the whole tool.
Why this matters:
- Project Information.Version is metadata. The running PLC binary
embeds it but exposing it at runtime requires the auto-generated
Project_Info library helpers (GetVersion etc.), which not every
project has wired up.
- A plain VAR_GLOBAL CONSTANT in a known-name GVL is the simplest,
most portable runtime anchor. Any IEC code can read it as
`_MCP_PROJECT_VERSION.sVersion`. The future read_running_version_online
tool will pull it via online connect + read_variable. The future
SSH transport variant can pull it via libcmd-symbol-export or by
grepping a debug log line that the project author can wire to
write at startup.
qualified_only is set so the symbol can't accidentally shadow a
same-named local in user code.
The GVL convention will be exercised end-to-end on MCPTest running
on the local soft PLC (port 11740) once the read_running_version_online
tool ships -- that's the next ship in this sequence.
Per user feedback. Previous behaviour treated 'no version yet' as
0.0.0.0 and bumped from there, so the very first call with level=build
produced 0.0.0.1 -- awkward as a canonical starting point. Most
projects start tracking at 1.0.0.0 the moment they turn on versioning.
New behaviour: if Project Information.version is None / empty / '0.0.0.0',
the tool seeds the value at 1.0.0.0 directly and ignores the level
argument for that one call. Subsequent calls bump per the level as
before.
Test cases:
None + level=build -> 1.0.0.0 (seed)
None + level=major -> 1.0.0.0 (seed)
'' + level=minor -> 1.0.0.0 (seed)
0.0.0.0 + level=revision -> 1.0.0.0 (seed)
1.0.0.0 + level=build -> 1.0.0.1
1.0.0.0 + level=minor -> 1.1.0.0
1.2.3.4 + level=major -> 2.0.0.0
New MCP tool that increments one part of the 4-part
Project Information.Version field of the primary project, saves the
project, and reports the before/after.
Behaviour:
- level=major -> bump major, reset minor/revision/build to 0
- level=minor -> bump minor, reset revision/build to 0
- level=revision-> bump revision, reset build to 0
- level=build -> bump build only
Convention follows the rest of CODESYS / 3S / WAGO library practice
(visible in any X33 library reference like 'WagoAppCanLayer2,
1.6.1.4 (WAGO)'):
Major -- incompatible API break (rename FB, change public
signature, remove method).
Minor -- backward-compatible feature add.
Revision -- bug fix only, no API change.
Build -- internal / CI counter, often 0 for hand-released.
Implementation notes:
- Project Information lives as the first child node of the project
root. Its .version property is read/written directly; IronPython
coerces strings like '1.2.3.4' to a System.Version on assignment,
str(System.Version) gives the dotted form back. None / empty /
unset is treated as '0.0.0.0'.
- Verified live against X33 (MRCodesysX33_0021): set version to
'1.0.0.0' from None, project.save() persisted it; reload via
primary_project.get_children() found the same value. Probe done
via the inject-once.mjs bridge against the live watcher in PID
23056 before this commit landed.
- project.save() is called after the bump so the new value sticks
in the .project file. Soft-fails on save error (visible WARNING
in DEBUG output but the bump itself is still reported as
successful) so a save permission glitch doesn't mask the actual
version change.
Used by the X33 GitLab project's "version in README header" workflow:
the version surfaces at the top of README.md (and at the top of the
library list once list_project_libraries gets enriched in a follow-up
commit).