Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,27 @@ jobs:
poetry-version: "1.8.5"
- name: "Linting: markdownlint"
run: "poetry run invoke markdownlint"
check-compatibility-matrix:
runs-on: "ubuntu-22.04"
env:
INVOKE_NAUTOBOT_DEV_EXAMPLE_LOCAL: "True"
steps:
- name: "Check out repository code"
uses: "actions/checkout@v4"
- name: "Setup environment"
uses: "networktocode/gh-action-setup-poetry-environment@v6"
with:
poetry-version: "1.8.5"
- name: "Linting: check-compatibility-matrix"
run: "poetry run invoke check-compatibility-matrix"
check-in-docker:
needs:
- "ruff-format"
- "ruff-lint"
- "poetry"
- "yamllint"
- "markdownlint"
- "check-compatibility-matrix"
runs-on: "ubuntu-22.04"
strategy:
fail-fast: true
Expand Down
1 change: 1 addition & 0 deletions changes/88.housekeeping
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added a new invoke command to check the accuracy of the Compatibility Matrix documentation file and optionally fix it automatically.
2 changes: 2 additions & 0 deletions docs/dev/release_checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ Every minor version release should refresh `poetry.lock`, so that it lists the m

If there are any changes to the compatibility matrix (such as a bump in the minimum supported Nautobot version), update it accordingly.

This can be updated automatically using `invoke check-compatibility-matrix --fix`.

Commit any resulting changes from the following sections to the documentation before proceeding with the release.

!!! tip
Expand Down
202 changes: 202 additions & 0 deletions tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,8 @@ def tests(context, failfast=False, keepdb=False, lint_only=False):
build_and_check_docs(context)
print("Checking app config schema...")
validate_app_config(context)
print("Checking Compatibility Matrix...")
check_compatibility_matrix(context)
if not lint_only:
print("Running unit tests...")
unittest(context, failfast=failfast, keepdb=keepdb, coverage=True, skip_docs_build=True)
Expand Down Expand Up @@ -985,3 +987,203 @@ def validate_app_config(context):
"""Validate the app config based on the app config schema."""
start(context, service="nautobot")
nbshell(context, plain=True, file="development/app_config_schema.py", env={"APP_CONFIG_SCHEMA_COMMAND": "validate"})


def parse_poetry_version_constraint(constraint):
"""Parse a Poetry version constraint and return (min_version, max_version) as strings."""

def max_version(version, part):
# For display, e.g. 2.0.0 -> 2.99.99 for major, 2.4.0 -> 2.4.99 for minor
parts = [int(x) for x in version.split(".")]
while len(parts) < 3:
parts.append(0)
if part == "major":
return f"{parts[0]}.99.99"
elif part == "minor":
return f"{parts[0]}.{parts[1]}.99"
elif part == "patch":
return f"{parts[0]}.{parts[1]}.{parts[2]}"
return version

def parse_single_bound(part):
part = part.strip()
m = re.match(r">=\s*([0-9.]+)", part)
if m:
return m[1], None
m = re.match(r">\s*([0-9.]+)", part)
if m:
v = m[1]
v_parts = [int(x) for x in v.split(".")]
while len(v_parts) < 3:
v_parts.append(0)
return f"{v_parts[0]}.{v_parts[1]}.{v_parts[2] + 1}", None
m = re.match(r"<=\s*([0-9.]+)", part)
if m:
return None, m[1]
m = re.match(r"<\s*([0-9.]+)", part)
if m:
v = m[1]
v_parts = [int(x) for x in v.split(".")]
if v_parts[1] > 0:
max_v = max_version(f"{v_parts[0]}.{v_parts[1]-1}", "minor")
else:
max_v = max_version(f"{v_parts[0]-1}", "major")
return None, max_v
return None, None

constraint = constraint.strip()
# Multiple constraints, e.g. ">=2.0.3,<3.0.0"
if "," in constraint:
min_v = None
max_v = None
for part in constraint.split(","):
part_min, part_max = parse_single_bound(part)
if part_min is not None:
min_v = part_min
if part_max is not None:
max_v = part_max
return min_v, max_v
# Caret ^
if constraint.startswith("^"):
v = constraint[1:]
parts = v.split(".")
if int(parts[0]) > 0:
max_v = max_version(parts[0], "major")
elif int(parts[0]) == 0 and len(parts) > 1 and int(parts[1]) > 0:
max_v = max_version(f"0.{parts[1]}", "minor")
else:
max_v = max_version(v, "patch")
return v, max_v
# Compatible ~=
if constraint.startswith("~="):
v = constraint[2:]
parts = v.split(".")
if len(parts) == 3:
min_v = v
max_v = max_version(f"{parts[0]}.{parts[1]}", "minor")
elif len(parts) == 2:
min_v = f"{parts[0]}.{parts[1]}.0"
max_v = max_version(f"{parts[0]}.{parts[1]}", "minor")
elif len(parts) == 1:
min_v = f"{parts[0]}.0.0"
max_v = max_version(parts[0], "major")
return min_v, max_v
# Tilde ~
if constraint.startswith("~"):
v = constraint[1:]
parts = v.split(".")
if len(parts) > 1:
max_v = max_version(f"{parts[0]}.{parts[1]}", "minor")
min_v = v
if len(parts) == 2:
min_v = f"{parts[0]}.{parts[1]}.0"
else:
min_v = f"{parts[0]}.0.0"
max_v = max_version(parts[0], "major")
return min_v, max_v
# Wildcard
if "*" in constraint:
parts = constraint.replace("*", "0").split(".")
if len(parts) == 3:
min_v = f"{parts[0]}.{parts[1]}.0"
max_v = max_version(f"{parts[0]}.{parts[1]}", "minor")
elif len(parts) == 2:
min_v = f"{parts[0]}.0.0"
max_v = max_version(parts[0], "major")
# This shouldn't happen, but handle it gracefully
elif len(parts) == 1:
min_v = "0.0.0"
max_v = "*"
return min_v, max_v
# Exact version or ==<version>
# e.g. 2.0.3 or ==2.0.3
m = re.match(r"==?\s*([0-9.]+)", constraint)
if m:
v = m[1]
return v, v
# >=, >, <=, < only (single bound)
min_v, max_v = parse_single_bound(constraint)
if min_v is not None or max_v is not None:
return (min_v, None) if min_v is not None else ("0.0.0", max_v)
# fallback
return constraint, None


@task(
help={
"fix": "Automatically fix issues found in the compatibility matrix. (default: False)",
}
)
def check_compatibility_matrix(context, fix=False):
"""Check compatibility matrix for the current Nautobot version."""

def read_file_lines(path):
if not path.exists():
raise Exit(f"File not found: {path}")
with open(path, "r") as f:
return f.readlines()

def get_last_table_line(lines):
return next((line.strip() for line in reversed(lines) if line.startswith("| ")), None)

def get_app_version():
return context.run("poetry version --short", hide=True).stdout.strip()

def get_nautobot_constraint(pyproject_path):
content = "".join(read_file_lines(pyproject_path))
match = re.search(
r'nautobot\s*=\s*"(.*?)"|nautobot\s*=\s*\{.*?version\s*=\s*"(.*?)"',
content,
)
if not match:
raise Exit("Nautobot version not found in the pyproject.toml file.")
return match.group(1) or match.group(2)

def update_matrix(lines, new_line, update_last=False):
if update_last:
lines[-1] = new_line
else:
lines.append(new_line)
return lines

# Paths
base = Path(__file__).parent
matrix_path = base / "docs" / "admin" / "compatibility_matrix.md"
pyproject_path = base / "pyproject.toml"

lines = read_file_lines(matrix_path)
last_line = get_last_table_line(lines)
if not last_line:
raise Exit("No compatibility matrix table found in the file.")

app_version = get_app_version()
nautobot_constraint = get_nautobot_constraint(pyproject_path)
nautobot_min, nautobot_max = parse_poetry_version_constraint(nautobot_constraint)
current_major_minor = ".".join(app_version.split(".")[:2])
expected_line = f"| {current_major_minor}.X | {nautobot_min} | {nautobot_max} |\n"

# Check if the last line is for the current app version
if f"{current_major_minor}.X " not in last_line.upper():
if not fix:
raise Exit(
f"Compatibility matrix for the current app version {app_version} is not up to date. "
"Please update the compatibility matrix in docs/admin/compatibility_matrix.md."
)
print("Updating compatibility matrix with the current app version...")
lines = update_matrix(lines, expected_line)
else:
# Check if Nautobot version constraints match
nautobot_min_version = last_line.split("|")[2].strip()
nautobot_max_version = last_line.split("|")[3].strip()
if nautobot_min_version != nautobot_min or nautobot_max_version != nautobot_max:
if not fix:
raise Exit(
f"Compatibility matrix for the current Nautobot version {nautobot_constraint} is not up to date. "
"Please update the compatibility matrix in docs/admin/compatibility_matrix.md."
)
print("Updating compatibility matrix with the current Nautobot version...")
lines = update_matrix(lines, expected_line, update_last=True)

if fix:
with open(matrix_path, "w") as f:
f.writelines(lines)
Loading