Skip to content
Merged
Changes from all 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
133 changes: 133 additions & 0 deletions .github/scripts/bump-nuget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""
Simplified package bumping for Codebelt service updates (Option B).

Only updates packages published by the triggering source repo.
Does NOT update Microsoft.Extensions.*, BenchmarkDotNet, or other third-party packages.
Does NOT parse TFM conditions - only bumps Codebelt/Cuemon/Savvyio packages to the triggering version.

Usage:
TRIGGER_SOURCE=cuemon TRIGGER_VERSION=10.3.0 python3 bump-nuget.py

Behavior:
- If TRIGGER_SOURCE is "cuemon" and TRIGGER_VERSION is "10.3.0":
- Cuemon.Core: 10.2.1 → 10.3.0
- Cuemon.Extensions.IO: 10.2.1 → 10.3.0
- Microsoft.Extensions.Hosting: 9.0.13 → UNCHANGED (not a Codebelt package)
- BenchmarkDotNet: 0.15.8 → UNCHANGED (not a Codebelt package)
"""

import re
import os
Comment on lines +20 to +21
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

The imports should follow PEP 8 conventions by grouping standard library imports separately from third-party imports (though all imports here are standard library). However, the from typing import statement should typically come after the regular imports. Consider reordering to: import os, import re, import sys, then from typing import Dict, List for consistency with Python conventions.

Suggested change
import re
import os
import os
import re

Copilot uses AI. Check for mistakes.
import sys
from typing import Dict, List

TRIGGER_SOURCE = os.environ.get("TRIGGER_SOURCE", "")
TRIGGER_VERSION = os.environ.get("TRIGGER_VERSION", "")

# Map of source repos to their package ID prefixes
SOURCE_PACKAGE_MAP: Dict[str, List[str]] = {
"cuemon": ["Cuemon."],
"xunit": ["Codebelt.Extensions.Xunit"],
"benchmarkdotnet": ["Codebelt.Extensions.BenchmarkDotNet"],
"bootstrapper": ["Codebelt.Bootstrapper"],
"newtonsoft-json": [
"Codebelt.Extensions.Newtonsoft.Json",
"Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft",
],
"aws-signature-v4": ["Codebelt.Extensions.AspNetCore.Authentication.AwsSignature"],
"unitify": ["Codebelt.Unitify"],
"yamldotnet": [
"Codebelt.Extensions.YamlDotNet",
"Codebelt.Extensions.AspNetCore.Mvc.Formatters.Text.Yaml",
],
"globalization": ["Codebelt.Extensions.Globalization"],
"asp-versioning": ["Codebelt.Extensions.Asp.Versioning"],
"swashbuckle-aspnetcore": ["Codebelt.Extensions.Swashbuckle"],
"savvyio": ["Savvyio."],
"shared-kernel": [],
}


def is_triggered_package(package_name: str) -> bool:
"""Check if package is published by the triggering source repo."""
if not TRIGGER_SOURCE:
return False
prefixes = SOURCE_PACKAGE_MAP.get(TRIGGER_SOURCE, [])
return any(package_name.startswith(prefix) for prefix in prefixes)


def main():
if not TRIGGER_SOURCE or not TRIGGER_VERSION:
print(
"Error: TRIGGER_SOURCE and TRIGGER_VERSION environment variables required"
)
print(f" TRIGGER_SOURCE={TRIGGER_SOURCE}")
print(f" TRIGGER_VERSION={TRIGGER_VERSION}")
sys.exit(1)

# Strip 'v' prefix if present in version
target_version = TRIGGER_VERSION.lstrip("v")

print(f"Trigger: {TRIGGER_SOURCE} @ {target_version}")
print(f"Only updating packages from: {TRIGGER_SOURCE}")
print()

try:
with open("Directory.Packages.props", "r") as f:
content = f.read()
except FileNotFoundError:
print("Error: Directory.Packages.props not found")
sys.exit(1)

changes = []
skipped_third_party = []

def replace_version(m: re.Match) -> str:
pkg = m.group(1)
current = m.group(2)

if not is_triggered_package(pkg):
skipped_third_party.append(f" {pkg} (skipped - not from {TRIGGER_SOURCE})")
return m.group(0)

if target_version != current:
changes.append(f" {pkg}: {current} → {target_version}")
return m.group(0).replace(
f'Version="{current}"', f'Version="{target_version}"'
Comment on lines +96 to +97
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

The version replacement logic on lines 96-97 has a potential bug. It uses m.group(0).replace() which will replace ALL occurrences of the pattern within the matched string. If the old version number appears elsewhere in the matched element (e.g., in an attribute name or comment), it could be incorrectly replaced. Consider using a more targeted replacement that specifically replaces the Version attribute value, or reconstruct the entire match string with the new version.

Suggested change
return m.group(0).replace(
f'Version="{current}"', f'Version="{target_version}"'
return re.sub(
r'\bVersion="[^"]+"',
f'Version="{target_version}"',
m.group(0),
count=1,

Copilot uses AI. Check for mistakes.
)

return m.group(0)

# Match PackageVersion elements (handles multiline)
pattern = re.compile(
r"<PackageVersion\b"
r'(?=[^>]*\bInclude="([^"]+)")'
r'(?=[^>]*\bVersion="([^"]+)")'
r"[^>]*>",
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

The regex pattern uses positive lookahead assertions to capture the Include and Version attributes. However, the pattern doesn't enforce the closing /> or </PackageVersion>. While the pattern captures up to >, it should be more specific to handle self-closing tags properly. Consider using r"<PackageVersion\b(?=[^>]*\bInclude=\"([^\"]+)\")(?=[^>]*\bVersion=\"([^\"]+)\")[^>]*/>" to specifically match self-closing tags, which is the standard format in Directory.Packages.props files.

Suggested change
r"[^>]*>",
r"[^>]*/>",

Copilot uses AI. Check for mistakes.
re.DOTALL,
)
new_content = pattern.sub(replace_version, content)

# Show results
if changes:
print(f"Updated {len(changes)} package(s) from {TRIGGER_SOURCE}:")
print("\n".join(changes))
else:
print(f"No packages from {TRIGGER_SOURCE} needed updating.")

if skipped_third_party:
print()
print(f"Skipped {len(skipped_third_party)} third-party package(s):")
print("\n".join(skipped_third_party[:5])) # Show first 5
if len(skipped_third_party) > 5:
print(f" ... and {len(skipped_third_party) - 5} more")

with open("Directory.Packages.props", "w") as f:
f.write(new_content)
Comment on lines +126 to +127
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

The script writes to Directory.Packages.props without creating a backup or using atomic write operations. If the write operation fails partway through (e.g., disk full, interrupted process), the file could be corrupted. Consider using a safer pattern: write to a temporary file first, then rename it to replace the original (atomic operation on most filesystems), or at least create a backup before writing.

Copilot uses AI. Check for mistakes.
Comment on lines +126 to +127
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

The error handling for FileNotFoundError is good, but other exceptions during file operations (like PermissionError, IOError when writing) are not caught. Consider adding exception handling around the file write operation on lines 126-127 to provide a more helpful error message if the write fails.

Suggested change
with open("Directory.Packages.props", "w") as f:
f.write(new_content)
try:
with open("Directory.Packages.props", "w") as f:
f.write(new_content)
except (PermissionError, OSError) as exc:
print(f"Error: Failed to write Directory.Packages.props: {exc}")
sys.exit(1)

Copilot uses AI. Check for mistakes.

return 0 if changes else 0 # Return 0 even if no changes (not an error)
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

The return statement on line 129 contains redundant logic. The expression 0 if changes else 0 always evaluates to 0 regardless of whether there are changes or not. If the intention is to return 0 in all cases (as the comment suggests), simplify this to return 0.

Suggested change
return 0 if changes else 0 # Return 0 even if no changes (not an error)
return 0 # Return 0 even if no changes (not an error)

Copilot uses AI. Check for mistakes.


if __name__ == "__main__":
sys.exit(main())
Loading