Skip to content
Merged
Show file tree
Hide file tree
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
193 changes: 105 additions & 88 deletions .github/scripts/bump-nuget.py
Original file line number Diff line number Diff line change
@@ -1,116 +1,133 @@
#!/usr/bin/env python3
"""
Bumps PackageVersion entries in Directory.Packages.props.
- For packages published by the triggering source repo: uses the known TRIGGER_VERSION.
- For all other packages: queries NuGet API for latest stable version.
- Preserves all XML formatting via regex (no ElementTree rewriting).
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, os, urllib.request, json, sys
import re
import os
import sys
from typing import Dict, List

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

# Maps source repo name → NuGet package ID prefixes published by that repo.
# Keep this aligned with what each repo actually publishes.
# 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",
],
"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",
],
"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.",
],
"globalization": ["Codebelt.Extensions.Globalization"],
"asp-versioning": ["Codebelt.Extensions.Asp.Versioning"],
"swashbuckle-aspnetcore": ["Codebelt.Extensions.Swashbuckle"],
"savvyio": ["Savvyio."],
"shared-kernel": [],
}


def nuget_latest(pkg: str) -> str | None:
"""Query NuGet flat container API for the latest stable version."""
try:
url = f"https://api.nuget.org/v3-flatcontainer/{pkg.lower()}/index.json"
with urllib.request.urlopen(url, timeout=15) as r:
versions = json.loads(r.read())["versions"]
stable = [v for v in versions if not re.search(r"-", v)]
return stable[-1] if stable else None
except Exception as e:
print(f" SKIP {pkg}: {e}", file=sys.stderr)
return None


def triggered_version(pkg: str) -> str | None:
"""Return TRIGGER_VERSION if pkg is published by the triggering source repo."""
if not TRIGGER_VERSION or not TRIGGER_SOURCE:
return None
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, [])
if any(pkg.startswith(p) for p in prefixes):
return TRIGGER_VERSION
return None


with open("Directory.Packages.props", "r") as f:
content = f.read()
return any(package_name.startswith(prefix) for prefix in prefixes)

changes = []

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)

Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

When TRIGGER_SOURCE is provided but doesn't exist in SOURCE_PACKAGE_MAP, the function is_triggered_package will return False for all packages (since prefixes will be an empty list from .get(TRIGGER_SOURCE, [])). This means the script will skip all packages and produce no changes. While this is safe, it would be better to add validation at the start of main() to check if TRIGGER_SOURCE exists in SOURCE_PACKAGE_MAP and exit with a clear error message if not. This would help catch configuration errors early.

Suggested change
if TRIGGER_SOURCE not in SOURCE_PACKAGE_MAP:
print(
f"Error: Unknown TRIGGER_SOURCE '{TRIGGER_SOURCE}'. "
f"Expected one of: {', '.join(SOURCE_PACKAGE_MAP.keys())}"
)
sys.exit(1)

Copilot uses AI. Check for mistakes.
def replace_version(m: re.Match) -> str:
pkg = m.group(1)
current = m.group(2)
# Strip 'v' prefix if present in version
target_version = TRIGGER_VERSION.lstrip("v")

Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

Consider adding validation for TRIGGER_VERSION format to ensure it follows semantic versioning (e.g., "1.2.3" or "1.2.3-beta"). Invalid version strings could be written to Directory.Packages.props, potentially breaking builds. A simple regex check like re.match(r'^\d+\.\d+\.\d+(-[\w.]+)?$', target_version) would catch obvious mistakes early.

Suggested change
# Validate that the target version looks like a semantic version (e.g. 1.2.3 or 1.2.3-beta)
if not re.match(r"^\d+\.\d+\.\d+(-[\w.]+)?$", target_version):
print(f"Error: Invalid TRIGGER_VERSION format: {TRIGGER_VERSION!r}")
print(" Expected a semantic version like '1.2.3' or '1.2.3-beta'.")
sys.exit(1)

Copilot uses AI. Check for mistakes.
new_ver = triggered_version(pkg) or nuget_latest(pkg) or current
print(f"Trigger: {TRIGGER_SOURCE} @ {target_version}")
print(f"Only updating packages from: {TRIGGER_SOURCE}")
print()

if new_ver != current:
changes.append(f" {pkg}: {current} → {new_ver}")
return m.group(0).replace(f'Version="{current}"', f'Version="{new_ver}"')


pattern = re.compile(
r'<PackageVersion\b'
r'(?=[^>]*\bInclude="([^"]+)")'
r'(?=[^>]*\bVersion="([^"]+)")'
r'[^>]*>',
re.DOTALL,
)
new_content = pattern.sub(replace_version, content)

if changes:
print(f"Bumped {len(changes)} package(s):")
print("\n".join(changes))
else:
print("All packages already at target versions.")

with open("Directory.Packages.props", "w") as f:
f.write(new_content)
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}"'
)

return m.group(0)

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

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

The regex pattern correctly uses re.DOTALL to match multiline PackageVersion elements, but it will match ALL PackageVersion elements regardless of ItemGroup conditions (e.g., those within Condition="$(TargetFramework.StartsWith('net9'))" blocks). This means if a Cuemon package appears in multiple conditional ItemGroups with different versions for different target frameworks, they will ALL be updated to the same TRIGGER_VERSION. This may be intentional per the script's documentation ("Does NOT parse TFM conditions"), but could lead to issues if different TFMs should have different package versions. Consider documenting this behavior more explicitly or adding a warning if conditional ItemGroups are detected.

Copilot uses AI. Check for mistakes.
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)

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

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

The return statement is redundant since both branches return 0. This could be simplified to just 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())
37 changes: 7 additions & 30 deletions .github/workflows/service-update.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,33 +93,9 @@ jobs:
env:
NEW_VERSION: ${{ steps.newver.outputs.new }}

- name: Bump test runner Docker tag
run: |
[ -f testenvironments.json ] || exit 0
LATEST=$(curl -sf "https://hub.docker.com/v2/repositories/codebeltnet/ubuntu-testrunner/tags/?page_size=10&ordering=last_updated" \
| python3 -c "
import sys, json
tags = [t['name'] for t in json.load(sys.stdin)['results'] if 'latest' not in t['name']]
print(tags[0] if tags else '')
")
[ -n "$LATEST" ] && \
sed -i "s|codebeltnet/ubuntu-testrunner:[^\"]*|codebeltnet/ubuntu-testrunner:${LATEST}|g" \
testenvironments.json && echo "Bumped to $LATEST"

- name: Bump NGINX Alpine version
run: |
[ -f .docfx/Dockerfile.docfx ] || exit 0
LATEST=$(curl -sf "https://hub.docker.com/v2/repositories/library/nginx/tags/?page_size=50&name=alpine&ordering=last_updated" \
| python3 -c "
import sys, json, re
data = json.load(sys.stdin)
tags = [t['name'] for t in data['results'] if re.match(r'^\d+\.\d+\.\d+-alpine$', t['name'])]
if tags:
print(sorted(tags, key=lambda v: list(map(int, v.replace('-alpine','').split('.'))))[-1])
")
[ -n "$LATEST" ] && \
sed -i "s|NGINX_VERSION=.*|NGINX_VERSION=${LATEST}|g" \
.docfx/Dockerfile.docfx && echo "Bumped NGINX to $LATEST"
# Note: Docker image bumps removed in favor of manual updates
# The automated selection was picking wrong variants (e.g., mono-* instead of standard)
# TODO: Move to hosted service for smarter image selection
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

The comment says "smarter image selection" but doesn't specify what "smarter" means. Consider being more specific about what improvements are needed, such as "image selection that filters by architecture and excludes variant tags like mono-*" or "automated selection with maintainer-defined filter rules". This will help whoever implements the hosted service understand the requirements.

Suggested change
# TODO: Move to hosted service for smarter image selection
# TODO: Move to a hosted service that selects Docker images by architecture, excludes mono-* and other unwanted variants, and applies maintainer-defined filter rules for tag selection.

Copilot uses AI. Check for mistakes.

- name: Show diff (dry run)
if: ${{ github.event.inputs.dry_run == 'true' }}
Expand All @@ -146,11 +122,12 @@ jobs:
echo "This is a service update that focuses on package dependencies." > pr_body.txt
echo "" >> pr_body.txt
echo "Automated changes:" >> pr_body.txt
echo "- NuGet package versions bumped to latest compatible" >> pr_body.txt
echo "- Codebelt/Cuemon package versions bumped to latest compatible" >> pr_body.txt
echo "- PackageReleaseNotes.txt updated for v${NEW}" >> pr_body.txt
echo "- CHANGELOG.md entry added for v${NEW}" >> pr_body.txt
echo "- Docker test runner tag bumped (if applicable)" >> pr_body.txt
echo "- NGINX Alpine version bumped (if applicable)" >> pr_body.txt
echo "" >> pr_body.txt
echo "Note: Third-party packages (Microsoft.Extensions.*, BenchmarkDotNet, etc.) are not auto-updated." >> pr_body.txt
echo "Use Dependabot or manual updates for those." >> pr_body.txt
echo "" >> pr_body.txt
echo "Generated by codebelt-aicia" >> pr_body.txt
if [ -n "$SOURCE" ] && [ -n "$SRC_VER" ]; then
Expand Down
Loading