|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Sanity check: an AL2023 vLLM/SGLang *server* config must not declare a |
| 3 | +framework version that is *behind* the upstream commit it pins. |
| 4 | +
|
| 5 | +Version contract for these images: |
| 6 | +
|
| 7 | + framework_version == "<upstream_release>[+dlc<n>]" |
| 8 | +
|
| 9 | +and the pinned source ref (``sglang_ref`` / ``vllm_ref``) must resolve to an |
| 10 | +upstream state that is *at least* ``<upstream_release>``. When the ref is moved |
| 11 | +ahead of the latest upstream release tag (e.g. onto ``main`` past ``v0.5.14``), |
| 12 | +``framework_version`` must be bumped so its ``<upstream_release>`` equals that |
| 13 | +latest tag. |
| 14 | +
|
| 15 | +This guards against the failure mode where the source ref is bumped but |
| 16 | +``framework_version`` is left stale (e.g. ref advanced past ``v0.5.14`` while |
| 17 | +``framework_version`` stayed ``0.5.13+dlc1``). That stale value gets baked into |
| 18 | +the container as ``SETUPTOOLS_SCM_PRETEND_VERSION`` and into the CVE-scanner |
| 19 | +metadata, mislabelling the shipped framework version. |
| 20 | +
|
| 21 | +Runs on the CI host (needs network + the GitHub API), one config at a time: |
| 22 | +
|
| 23 | + python3 test/sanity/scripts/check_framework_version_currency.py \\ |
| 24 | + --config-file .github/config/image/sglang/ec2-amzn2023.yml |
| 25 | +
|
| 26 | +Set ``GITHUB_TOKEN`` (or ``GH_TOKEN``) to raise the API rate limit. |
| 27 | +""" |
| 28 | + |
| 29 | +import argparse |
| 30 | +import json |
| 31 | +import os |
| 32 | +import re |
| 33 | +import subprocess |
| 34 | +import sys |
| 35 | +import urllib.error |
| 36 | +import urllib.request |
| 37 | + |
| 38 | +from packaging.version import InvalidVersion, Version |
| 39 | + |
| 40 | +# metadata.framework -> (upstream repo, config field holding the source ref). |
| 41 | +# Only the from-source *server* images follow the "<release>+dlc<n>" contract. |
| 42 | +FRAMEWORK_SOURCES = { |
| 43 | + "sglang_server": ("sgl-project/sglang", "sglang_ref"), |
| 44 | + "vllm_server": ("vllm-project/vllm", "vllm_ref"), |
| 45 | +} |
| 46 | + |
| 47 | +# Strict release tags only (no rc/dev/post/a/b): v1.2.3 or 1.2.3 |
| 48 | +_RELEASE_TAG_RE = re.compile(r"^v?\d+(?:\.\d+)*$") |
| 49 | + |
| 50 | + |
| 51 | +def load_config(path): |
| 52 | + """Return the parsed config dict. Uses PyYAML if available, else yq.""" |
| 53 | + try: |
| 54 | + import yaml |
| 55 | + |
| 56 | + with open(path) as f: |
| 57 | + return yaml.safe_load(f) |
| 58 | + except ImportError: |
| 59 | + out = subprocess.check_output(["yq", "-o=json", ".", path], text=True) |
| 60 | + return json.loads(out) |
| 61 | + |
| 62 | + |
| 63 | +def to_version(tag_or_version): |
| 64 | + """Parse a tag name or version string into a ``Version`` (drops a ``v`` |
| 65 | + prefix, e.g. ``v0.5.14`` -> ``Version('0.5.14')``).""" |
| 66 | + return Version(str(tag_or_version).lstrip("v")) |
| 67 | + |
| 68 | + |
| 69 | +def github_get(url): |
| 70 | + """GET a GitHub API URL, returning parsed JSON. Uses a token if present.""" |
| 71 | + headers = { |
| 72 | + "Accept": "application/vnd.github+json", |
| 73 | + "User-Agent": "dlc-framework-version-currency-check", |
| 74 | + } |
| 75 | + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") |
| 76 | + if token: |
| 77 | + headers["Authorization"] = f"Bearer {token}" |
| 78 | + req = urllib.request.Request(url, headers=headers) |
| 79 | + with urllib.request.urlopen(req, timeout=30) as resp: |
| 80 | + return json.loads(resp.read().decode()) |
| 81 | + |
| 82 | + |
| 83 | +def release_tags(repo): |
| 84 | + """Return upstream strict-release tags as ``(Version, tag_name)`` pairs, |
| 85 | + sorted newest-first.""" |
| 86 | + tags = [] |
| 87 | + for page in range(1, 6): # up to 500 tags; newest first |
| 88 | + batch = github_get(f"https://api.github.com/repos/{repo}/tags?per_page=100&page={page}") |
| 89 | + if not batch: |
| 90 | + break |
| 91 | + for entry in batch: |
| 92 | + name = entry["name"] |
| 93 | + if _RELEASE_TAG_RE.match(name): |
| 94 | + try: |
| 95 | + tags.append((to_version(name), name)) |
| 96 | + except InvalidVersion: |
| 97 | + continue |
| 98 | + if len(batch) < 100: |
| 99 | + break |
| 100 | + return sorted(set(tags), key=lambda t: t[0], reverse=True) |
| 101 | + |
| 102 | + |
| 103 | +def ref_at_or_ahead_of(repo, tag, ref): |
| 104 | + """True if ``ref`` is at or ahead of ``tag`` in ``repo`` history.""" |
| 105 | + data = github_get(f"https://api.github.com/repos/{repo}/compare/{tag}...{ref}") |
| 106 | + status = data.get("status") |
| 107 | + if status in ("identical", "ahead"): |
| 108 | + return True |
| 109 | + if status == "behind": |
| 110 | + return False |
| 111 | + if status == "diverged": |
| 112 | + # ref is on a newer/parallel line (e.g. main past a release-branch tag). |
| 113 | + # Treat it as "at least" the tag when it carries at least as much unique |
| 114 | + # history as the tag does. |
| 115 | + return data.get("ahead_by", 0) >= data.get("behind_by", 0) |
| 116 | + return False |
| 117 | + |
| 118 | + |
| 119 | +def resolve_ref_release(repo, ref): |
| 120 | + """The highest strict-release tag that ``ref`` is at or ahead of, as a |
| 121 | + ``(Version, tag_name)`` pair, or ``None`` if it precedes all tags.""" |
| 122 | + for version, name in release_tags(repo): |
| 123 | + if ref_at_or_ahead_of(repo, name, ref): |
| 124 | + return version, name |
| 125 | + return None |
| 126 | + |
| 127 | + |
| 128 | +def check_config(config_path): |
| 129 | + """Check one config file. Returns (ok: bool, message: str).""" |
| 130 | + config = load_config(config_path) |
| 131 | + metadata = config.get("metadata", {}) |
| 132 | + build = config.get("build", {}) |
| 133 | + |
| 134 | + framework = metadata.get("framework", "") |
| 135 | + if framework not in FRAMEWORK_SOURCES: |
| 136 | + return True, f"SKIP: framework {framework!r} is not a vLLM/SGLang server image" |
| 137 | + |
| 138 | + repo, ref_field = FRAMEWORK_SOURCES[framework] |
| 139 | + ref = build.get(ref_field) |
| 140 | + if not ref: |
| 141 | + return True, f"SKIP: no {ref_field} in {config_path} (not a from-source build)" |
| 142 | + |
| 143 | + declared = to_version(metadata.get("framework_version")) |
| 144 | + if declared.is_devrelease: |
| 145 | + # A ".devN" version is a legacy setuptools_scm snapshot of an unreleased |
| 146 | + # upstream commit (e.g. the out-of-support hyperpod image) and does not |
| 147 | + # use the "<release>+dlc<n>" contract, so the check does not apply. Such |
| 148 | + # an image would be checked again once it adopts the +dlc convention. |
| 149 | + return True, f"SKIP: framework_version {declared} is a legacy dev snapshot" |
| 150 | + |
| 151 | + resolved = resolve_ref_release(repo, ref) |
| 152 | + if resolved is None: |
| 153 | + return True, f"SKIP: {ref[:10]} precedes all release tags of {repo}" |
| 154 | + resolved_version, resolved_tag = resolved |
| 155 | + |
| 156 | + # Compare on the release portion only, so the "+dlc<n>" local segment does |
| 157 | + # not read as newer than the bare upstream tag. |
| 158 | + declared_release = Version(declared.base_version) |
| 159 | + resolved_release = Version(resolved_version.base_version) |
| 160 | + |
| 161 | + detail = ( |
| 162 | + f"framework_version={metadata.get('framework_version')!r} vs " |
| 163 | + f"{ref_field}={ref[:10]} which is at upstream {resolved_tag} in {repo}" |
| 164 | + ) |
| 165 | + if declared_release < resolved_release: |
| 166 | + return False, ( |
| 167 | + f"framework_version is BEHIND the pinned ref: {detail}. " |
| 168 | + f"Bump framework_version's upstream to {resolved_release} " |
| 169 | + f"(keeping the +dlc<n> suffix)." |
| 170 | + ) |
| 171 | + if declared_release > resolved_release: |
| 172 | + # The ref has not (fully) reached the declared release. Common for a ref |
| 173 | + # pinned just before a release tag; warn but do not fail. |
| 174 | + return True, ( |
| 175 | + f"WARNING: framework_version is ahead of the pinned ref's latest " |
| 176 | + f"reached release: {detail}." |
| 177 | + ) |
| 178 | + return True, f"OK: {detail}" |
| 179 | + |
| 180 | + |
| 181 | +def main(): |
| 182 | + parser = argparse.ArgumentParser(description=__doc__) |
| 183 | + parser.add_argument( |
| 184 | + "--config-file", |
| 185 | + required=True, |
| 186 | + help="Path to the image config YAML to check.", |
| 187 | + ) |
| 188 | + args = parser.parse_args() |
| 189 | + |
| 190 | + try: |
| 191 | + ok, message = check_config(args.config_file) |
| 192 | + except (urllib.error.URLError, urllib.error.HTTPError) as exc: |
| 193 | + print(f"ERROR: GitHub API request failed: {exc}", file=sys.stderr) |
| 194 | + return 2 |
| 195 | + |
| 196 | + print(message) |
| 197 | + return 0 if ok else 1 |
| 198 | + |
| 199 | + |
| 200 | +if __name__ == "__main__": |
| 201 | + sys.exit(main()) |
0 commit comments