{"record":{"id":"e168d5f4f0f80f24","repo":"github/spec-kit","slug":"github-api-response-missing-valid-tag-name","errorCode":null,"errorMessage":"GitHub API response missing valid tag_name","messagePattern":"GitHub API response missing valid tag_name","errorType":"exception","errorClass":"ValueError","httpStatus":null,"severity":"warning","filePath":"src/specify_cli/_version.py","lineNumber":133,"sourceCode":"    \"\"\"\n    from .authentication.http import open_url\n\n    try:\n        with open_url(\n            GITHUB_API_LATEST,\n            timeout=5,\n            extra_headers={\"Accept\": \"application/vnd.github+json\"},\n        ) as resp:\n            payload = json.loads(\n                read_response_limited(\n                    resp,\n                    max_bytes=MAX_JSON_METADATA_BYTES,\n                    label=\"GitHub latest release\",\n                ).decode(\"utf-8\")\n            )\n            tag = payload.get(\"tag_name\")\n            if not isinstance(tag, str) or not tag:\n                raise ValueError(\"GitHub API response missing valid tag_name\")\n            return tag, None\n    except urllib.error.HTTPError as e:\n        # Order matters: HTTPError is a subclass of URLError.\n        # 403 (primary rate limit / abuse detection) and 429 (Too Many Requests /\n        # secondary rate limit) both get the actionable \"configure a token\" hint;\n        # every other status is surfaced verbatim as \"HTTP {code}\".\n        if e.code in (403, 429):\n            return None, _RESOLUTION_FAILURE_RATE_LIMITED\n        return None, f\"{_RESOLUTION_FAILURE_HTTP_PREFIX}{e.code}\"\n    except (urllib.error.URLError, OSError):\n        return None, _RESOLUTION_FAILURE_OFFLINE\n\n\ndef _parse_version_text(value: str) -> Version | None:\n    \"\"\"Parse version-like text after tag normalization, or return None.\"\"\"\n    normalized = _normalize_tag(value)\n    try:\n        return Version(normalized)","sourceCodeStart":115,"sourceCodeEnd":151,"githubUrl":"https://github.com/github/spec-kit/blob/bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c/src/specify_cli/_version.py#L115-L151","documentation":"Part of the CLI's version-resolution check: it fetches GitHub's latest-release API (api.github.com/repos/.../releases/latest) with a 5s timeout and size-limited body, parses JSON, and requires a non-empty string tag_name. A 200 response whose JSON lacks a usable tag_name raises this ValueError; HTTP 403/429 and network failures are handled separately as rate-limited/offline results.","triggerScenarios":"An intercepting proxy, captive portal, or misconfigured DNS returns HTTP 200 with an HTML/JSON body that parses as JSON but has no tag_name key (or tag_name is null/number); a GitHub Enterprise mirror or rewritten API URL that returns a different schema.","commonSituations":"Corporate proxies returning a 200 'login page' JSON; a repository that has no releases (API returns {'message': 'Not Found'} with 200 in some mirrors); future API schema changes; the raised ValueError is not among the caught HTTPError/URLError/OSError, so it can escape as an unhandled crash during version checks.","solutions":["Test the endpoint your machine actually reaches: curl -sS -H 'Accept: application/vnd.github+json' https://api.github.com/repos/github/spec-kit/releases/latest | jq .tag_name — if it is not a tag, fix proxy/DNS.","Configure the proxy's bypass list so api.github.com is not intercepted.","If you run a mirror, ensure it emits the standard release JSON including tag_name.","As a caller, catch ValueError around version resolution (or disable the update check) so a malformed response cannot crash your command."],"exampleFix":"# before\nlatest = resolve_latest_version()  # ValueError escapes on malformed 200\n# after\ntry:\n    latest = resolve_latest_version()\nexcept ValueError:\n    latest = None  # skip update check, continue","handlingStrategy":"fallback","validationCode":"import json, urllib.request\n\ndef github_release_has_tag(url: str) -> bool:\n    try:\n        with urllib.request.urlopen(url, timeout=5) as r:\n            payload = json.loads(r.read(1 << 20).decode(\"utf-8\"))\n        return isinstance(payload.get(\"tag_name\"), str) and bool(payload[\"tag_name\"])\n    except Exception:\n        return False","typeGuard":"from typing import Any\n\ndef has_valid_tag_name(payload: Any) -> bool:\n    \"\"\"Narrow a GitHub release JSON payload to one with a usable tag_name.\"\"\"\n    return (\n        isinstance(payload, dict)\n        and isinstance(payload.get(\"tag_name\"), str)\n        and bool(payload[\"tag_name\"])\n    )","tryCatchPattern":"try:\n    tag = fetch_latest_tag()\nexcept ValueError:\n    tag = None  # malformed upstream response; skip update check, don't crash\nexcept (urllib.error.URLError, OSError):\n    tag = None  # offline","preventionTips":["Treat the update check as best-effort: never let it abort the main command.","Bypass corporate proxies for api.github.com or set HTTPS_PROXY correctly.","Pin/disable the version check in automation if the network path is untrusted."],"tags":["network","github-api","version-check","proxy"],"backgroundTag":null,"analyzedSha":"bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c","analyzedAt":"2026-08-14T19:43:37.150Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}