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>
307 lines
13 KiB
Python
307 lines
13 KiB
Python
import sys, scriptengine as script_engine, traceback, re
|
|
|
|
# Bumps one part of the 4-part Project Information.version field of the
|
|
# primary project. Convention (per CODESYS / 3S / WAGO library practice):
|
|
#
|
|
# Major -- bump on incompatible API break.
|
|
# Minor -- bump on backward-compatible feature add.
|
|
# Revision -- bump on bug fix only (no API change).
|
|
# Build -- internal counter, often 0 for hand-released versions.
|
|
#
|
|
# Bumping a higher part resets all lower parts to 0 (e.g. bumping minor
|
|
# resets revision and build).
|
|
#
|
|
# The Version field is read/written as a property on the "Project
|
|
# Information" node (first child of the primary project). IronPython
|
|
# coerces strings like "1.2.3.4" to System.Version automatically; we
|
|
# stringify on the read side because str(System.Version) gives
|
|
# the dotted form back. None / empty are treated as "0.0.0.0".
|
|
|
|
LEVEL = "{LEVEL}" # major | minor | revision | build
|
|
|
|
VALID_LEVELS = ('major', 'minor', 'revision', 'build')
|
|
|
|
# Standard runtime-readable version anchor. Lives as a constant in a GVL
|
|
# under Application so any IEC code can read it as
|
|
# _MCP_PROJECT_VERSION.sVersion, and a future read_running_version_online
|
|
# tool can pull it via online connect + read_variable. Kept as
|
|
# qualified_only so it can't accidentally shadow a same-named local.
|
|
VERSION_GVL_NAME = '_MCP_PROJECT_VERSION'
|
|
VERSION_GVL_DECLARATION_TEMPLATE = (
|
|
"{attribute 'qualified_only'}\n"
|
|
"VAR_GLOBAL CONSTANT\n"
|
|
" sVersion : STRING := '%s';\n"
|
|
"END_VAR\n"
|
|
)
|
|
|
|
|
|
def parse_version(v):
|
|
"""Parse a version-like value into a 4-tuple of ints, defaulting missing
|
|
parts to 0. Accepts None, '', '1.2', '1.2.3', '1.2.3.4', or a
|
|
System.Version. Raises ValueError on anything that can't be parsed."""
|
|
if v is None:
|
|
return (0, 0, 0, 0)
|
|
s = str(v).strip()
|
|
if not s or s == 'None':
|
|
return (0, 0, 0, 0)
|
|
parts = s.split('.')
|
|
if len(parts) > 4:
|
|
raise ValueError("version '%s' has more than 4 parts" % s)
|
|
nums = []
|
|
for p in parts:
|
|
try:
|
|
nums.append(int(p))
|
|
except ValueError:
|
|
raise ValueError("version '%s' has non-integer part '%s'" % (s, p))
|
|
while len(nums) < 4:
|
|
nums.append(0)
|
|
return tuple(nums)
|
|
|
|
|
|
def read_version_from_gvl(primary_project):
|
|
"""When Project Information is missing, read the current version back
|
|
from the runtime anchor GVL (_MCP_PROJECT_VERSION.sVersion) so subsequent
|
|
bumps can resume from the actual current state instead of re-seeding to
|
|
1.0.0.0 on every call. Returns the version string or None if the GVL
|
|
doesn't exist yet (true first-run)."""
|
|
try:
|
|
app = getattr(primary_project, 'active_application', None)
|
|
except Exception:
|
|
app = None
|
|
if app is None:
|
|
try:
|
|
apps = primary_project.find('Application', True)
|
|
if apps:
|
|
app = apps[0]
|
|
except Exception:
|
|
pass
|
|
if app is None:
|
|
return None
|
|
try:
|
|
for child in app.get_children(False):
|
|
try:
|
|
if child.get_name() != VERSION_GVL_NAME:
|
|
continue
|
|
decl = child.textual_declaration.text or ''
|
|
m = re.search(r"sVersion\s*:\s*STRING\s*:=\s*'(\d+\.\d+\.\d+\.\d+)'", decl)
|
|
if m:
|
|
return m.group(1)
|
|
return None
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def maintain_version_gvl(primary_project, version_str):
|
|
"""Find or create the _MCP_PROJECT_VERSION GVL under the active
|
|
Application, and set its declaration so the running PLC carries the
|
|
project version as a constant string. Soft-fails on any error -- the
|
|
primary outcome of the bump (Project Information.Version) has already
|
|
succeeded by the time this is called, so a GVL creation failure is
|
|
logged as a WARNING but does not fail the whole tool."""
|
|
try:
|
|
app = getattr(primary_project, 'active_application', None)
|
|
except Exception:
|
|
app = None
|
|
if app is None:
|
|
try:
|
|
apps = primary_project.find('Application', True)
|
|
if apps:
|
|
app = apps[0]
|
|
except Exception:
|
|
pass
|
|
if app is None:
|
|
print("WARNING: no active Application found -- cannot maintain %s GVL" % VERSION_GVL_NAME)
|
|
return False
|
|
|
|
decl = VERSION_GVL_DECLARATION_TEMPLATE % version_str
|
|
|
|
# Try to find existing GVL with this name
|
|
existing = None
|
|
try:
|
|
for child in app.get_children(False):
|
|
try:
|
|
if child.get_name() == VERSION_GVL_NAME:
|
|
existing = child
|
|
break
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
print("WARNING: walking Application children failed: %s" % e)
|
|
|
|
if existing is not None:
|
|
try:
|
|
existing.textual_declaration.replace(decl)
|
|
print("DEBUG: updated %s -> sVersion := '%s'" % (VERSION_GVL_NAME, version_str))
|
|
return True
|
|
except Exception as e:
|
|
print("WARNING: failed to update existing %s declaration: %s" % (VERSION_GVL_NAME, e))
|
|
return False
|
|
|
|
# Create it
|
|
if not hasattr(app, 'create_gvl'):
|
|
print("WARNING: Application object doesn't expose create_gvl -- cannot create %s" % VERSION_GVL_NAME)
|
|
return False
|
|
try:
|
|
new_gvl = app.create_gvl(name=VERSION_GVL_NAME)
|
|
if new_gvl is None:
|
|
print("WARNING: create_gvl returned None for %s" % VERSION_GVL_NAME)
|
|
return False
|
|
new_gvl.textual_declaration.replace(decl)
|
|
print("DEBUG: created %s with sVersion := '%s'" % (VERSION_GVL_NAME, version_str))
|
|
return True
|
|
except Exception as e:
|
|
print("WARNING: failed to create %s: %s" % (VERSION_GVL_NAME, e))
|
|
return False
|
|
|
|
|
|
def bump(parts, level):
|
|
major, minor, revision, build = parts
|
|
if level == 'major':
|
|
return (major + 1, 0, 0, 0)
|
|
if level == 'minor':
|
|
return (major, minor + 1, 0, 0)
|
|
if level == 'revision':
|
|
return (major, minor, revision + 1, 0)
|
|
if level == 'build':
|
|
return (major, minor, revision, build + 1)
|
|
raise ValueError("unknown bump level '%s' (must be one of %s)" % (level, ', '.join(VALID_LEVELS)))
|
|
|
|
|
|
try:
|
|
if LEVEL not in VALID_LEVELS:
|
|
raise ValueError("level must be one of %s, got '%s'" % (', '.join(VALID_LEVELS), LEVEL))
|
|
|
|
primary_project = ensure_project_open(PROJECT_FILE_PATH)
|
|
|
|
# Find the Project Information node via the official is_project_info
|
|
# marker rather than name-matching. Walk the project tree -- the node is
|
|
# usually at the root, but locating it via the marker is robust against
|
|
# localised IDE display names ('Projektinformation' in DE, etc.) and
|
|
# against projects where the node lives at a different depth.
|
|
pi = None
|
|
def _find_pi(node, depth=0, max_depth=4):
|
|
if depth > max_depth:
|
|
return None
|
|
try:
|
|
if getattr(node, 'is_project_info', False):
|
|
return node
|
|
except Exception:
|
|
pass
|
|
try:
|
|
for c in node.get_children(False):
|
|
hit = _find_pi(c, depth + 1, max_depth)
|
|
if hit is not None:
|
|
return hit
|
|
except Exception:
|
|
pass
|
|
return None
|
|
pi = _find_pi(primary_project)
|
|
|
|
# Some projects (notably ones created from the Standard template via the
|
|
# scripting create_project flow) have no Project Information node at all
|
|
# -- the IDE adds it lazily the first time the user opens
|
|
# Project menu -> Project Information. We don't have a documented way to
|
|
# create one via scripting, so handle it gracefully: skip the metadata
|
|
# write but still maintain the runtime-readable GVL, which is the
|
|
# source-of-truth at runtime anyway. The user can add Project Information
|
|
# manually via the IDE later if they want the metadata side too.
|
|
pi_missing = pi is None
|
|
if pi_missing:
|
|
print("WARNING: Project Information node not found in project tree -- "
|
|
"skipping metadata write. The runtime anchor (GVL) will still be "
|
|
"maintained. To add the Project Information node, open the "
|
|
"Project menu -> Project Information in the IDE; subsequent bumps "
|
|
"will then update both metadata and GVL.")
|
|
# Fall back to reading the existing GVL so we resume from the actual
|
|
# current version instead of re-seeding to 1.0.0.0 every call.
|
|
before_raw = read_version_from_gvl(primary_project)
|
|
if before_raw:
|
|
print("DEBUG: Project Information missing, resuming from GVL: %s" % before_raw)
|
|
else:
|
|
# Cross-check pi.version against the runtime-anchor GVL and take the
|
|
# max of the two as the resume point. Drift between the two sides
|
|
# happens when a release is finished by an external (non-MCP) script
|
|
# that updates one but not the other -- the GVL gets refreshed via
|
|
# inject-once writes, and pi.version gets refreshed via direct .project
|
|
# binary edits, but a script that touches only one leaves the other
|
|
# stale. Without the cross-check, the next bump would silently regress
|
|
# the version (observed: pi.version stuck at 1.0.0.0 while GVL was at
|
|
# 1.2.1.0 -> minor bump gave 1.1.0.0, colliding with an existing tag).
|
|
# Taking the max is always safe: the GVL only ever moves forward (set
|
|
# by maintain_version_gvl on every bump), and pi.version only ever
|
|
# moves forward (set by pi.version assignment). The higher of the
|
|
# two is the true latest version regardless of which side drifted.
|
|
pi_raw = pi.version
|
|
gvl_raw = read_version_from_gvl(primary_project)
|
|
pi_parts = parse_version(pi_raw) if pi_raw is not None else (0, 0, 0, 0)
|
|
gvl_parts = parse_version(gvl_raw) if gvl_raw else (0, 0, 0, 0)
|
|
if pi_parts >= gvl_parts:
|
|
before_raw = pi_raw
|
|
else:
|
|
print("WARNING: Project Information.Version (%s) is BEHIND the runtime anchor "
|
|
"GVL (%s) -- this happens when a previous release was finished by an "
|
|
"external script that updated the GVL but not the .project metadata. "
|
|
"Using the GVL value as the resume point so the bump doesn't regress." % (
|
|
pi_raw, gvl_raw))
|
|
before_raw = gvl_raw
|
|
# Heal pi.version forward to the GVL value before the bump so this
|
|
# warning doesn't recur on the next call.
|
|
try:
|
|
pi.version = gvl_raw
|
|
print("DEBUG: healed Project Information.Version: %s -> %s (matching GVL)" % (
|
|
pi_raw, gvl_raw))
|
|
except Exception as heal_e:
|
|
print("WARNING: could not heal Project Information.Version: %s" % heal_e)
|
|
before_str = str(before_raw) if before_raw is not None else None
|
|
|
|
# First-run convention: if no version is set yet, seed at 1.0.0.0 instead
|
|
# of treating "no version" as 0.0.0.0 + bump (which would give 0.0.0.1
|
|
# for level=build, awkward for a first canonical version). Most projects
|
|
# start tracking at 1.0.0.0 when they first turn on versioning, and the
|
|
# level argument is moot for the seed step.
|
|
seed_check = parse_version(before_raw)
|
|
if seed_check == (0, 0, 0, 0) and (before_raw is None or str(before_raw).strip() in ('', '0.0.0.0', 'None')):
|
|
after_parts = (1, 0, 0, 0)
|
|
after_str = '1.0.0.0'
|
|
print("DEBUG: bump_project_version: no prior version -- seeding to 1.0.0.0 (level=%s ignored on first run)" % LEVEL)
|
|
else:
|
|
before_parts = seed_check
|
|
after_parts = bump(before_parts, LEVEL)
|
|
after_str = '%d.%d.%d.%d' % after_parts
|
|
print("DEBUG: bump_project_version: level=%s before=%s -> after=%s" % (
|
|
LEVEL, before_str, after_str))
|
|
|
|
if not pi_missing:
|
|
pi.version = after_str
|
|
|
|
# Maintain the runtime-readable version anchor (_MCP_PROJECT_VERSION GVL)
|
|
# so the running PLC carries the same string. Soft-fails so the primary
|
|
# bump still reports success even if GVL creation hits an edge case.
|
|
gvl_ok = maintain_version_gvl(primary_project, after_str)
|
|
|
|
try:
|
|
primary_project.save()
|
|
print("DEBUG: project.save() succeeded after version bump.")
|
|
except Exception as save_e:
|
|
print("WARNING: project.save() raised %s -- bump applied in memory but may not persist across IDE close." % save_e)
|
|
|
|
if pi_missing:
|
|
print("Project Information.Version: (skipped -- node missing) -> %s" % after_str)
|
|
else:
|
|
print("Project Information.Version: %s -> %s" % (before_str, after_str))
|
|
if gvl_ok:
|
|
print("Runtime anchor: %s.sVersion := '%s'" % (VERSION_GVL_NAME, after_str))
|
|
else:
|
|
print("Runtime anchor: %s NOT updated (see WARNING above)" % VERSION_GVL_NAME)
|
|
print("SCRIPT_SUCCESS: bump_project_version complete.")
|
|
sys.exit(0)
|
|
except Exception as e:
|
|
detailed = traceback.format_exc()
|
|
msg = "Error in bump_project_version for project '%s': %s\n%s" % (
|
|
PROJECT_FILE_PATH, e, detailed)
|
|
print(msg)
|
|
print("SCRIPT_ERROR: %s" % msg)
|
|
sys.exit(1)
|