Regenerate Client #55
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Regenerate Client | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| title: | |
| description: PR title, auto-generated from the spec diff in www. | |
| required: false | |
| type: string | |
| summary: | |
| description: PR body summarizing the spec changes. | |
| required: false | |
| type: string | |
| jobs: | |
| regenerate: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Generate GitHub App token | |
| id: app-token | |
| uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 | |
| with: | |
| client-id: Iv23liKBX2RYMoZIYuKa | |
| private-key: ${{ secrets.HOTDATA_AUTOMATION_PRIVATE_KEY }} | |
| owner: hotdata-dev | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| token: ${{ steps.app-token.outputs.token }} | |
| fetch-depth: 0 | |
| fetch-tags: true | |
| - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 | |
| with: | |
| python-version: '3.12' | |
| - name: Fetch merged OpenAPI spec | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| run: | | |
| curl -sS -f -L \ | |
| -H "Accept: application/vnd.github.v3.raw" \ | |
| -H "Authorization: Bearer $GH_TOKEN" \ | |
| https://api.github.com/repos/hotdata-dev/www.hotdata.dev/contents/api/openapi.yaml \ | |
| -o openapi.yaml | |
| - name: Clean existing source | |
| run: rm -rf src/ | |
| # pyproject.toml is hand-maintained (see .openapi-generator-ignore), so the | |
| # generator no longer stamps the version. Bump the patch version directly, | |
| # rewriting the same `^version = "X"` line scripts/release.sh treats as the | |
| # source of truth. | |
| - name: Bump package patch version in pyproject.toml | |
| id: pkg | |
| run: | | |
| version=$(python3 - <<'PY' | |
| import re, pathlib | |
| path = pathlib.Path("pyproject.toml") | |
| text = path.read_text() | |
| m = re.search(r'(?m)^version = "(\d+)\.(\d+)\.(\d+)"', text) | |
| if not m: | |
| raise SystemExit("could not find a semver version in pyproject.toml") | |
| new = f"{int(m[1])}.{int(m[2])}.{int(m[3]) + 1}" | |
| text, n = re.subn(r'(?m)^version = "[^"]+"', f'version = "{new}"', text, count=1) | |
| if n != 1: | |
| raise SystemExit("failed to rewrite version in pyproject.toml") | |
| path.write_text(text) | |
| print(new) | |
| PY | |
| ) | |
| echo "version=$version" >> "$GITHUB_OUTPUT" | |
| - name: Generate client | |
| run: | | |
| npx @openapitools/openapi-generator-cli generate \ | |
| -i openapi.yaml \ | |
| -g python \ | |
| -o . \ | |
| -t .openapi-generator-templates \ | |
| --additional-properties=packageName=hotdata,projectName=hotdata,packageVersion=${{ steps.pkg.outputs.version }},gitUserId=hotdata-dev,gitRepoId=sdk-python \ | |
| --skip-validate-spec | |
| # pyproject.toml/requirements*.txt are hand-maintained, so the generator no | |
| # longer writes them. Guard against silent drift: regenerate the generator's | |
| # pyproject into a throwaway dir and fail if it now requires a runtime | |
| # dependency we don't declare. Version floors are intentionally raised (e.g. | |
| # for security), so compare dependency NAMES only, never the specs. | |
| - name: Check for generator dependency drift | |
| run: | | |
| npx @openapitools/openapi-generator-cli generate \ | |
| -i openapi.yaml \ | |
| -g python \ | |
| -o /tmp/gencheck \ | |
| -t .openapi-generator-templates \ | |
| --additional-properties=packageName=hotdata,projectName=hotdata,gitUserId=hotdata-dev,gitRepoId=sdk-python \ | |
| --skip-validate-spec >/dev/null | |
| python3 - <<'PY' | |
| import re, sys, pathlib, tomllib | |
| def dep_names(path): | |
| data = tomllib.loads(pathlib.Path(path).read_text()) | |
| specs = list(data.get("project", {}).get("dependencies", []) or []) | |
| poetry = data.get("tool", {}).get("poetry", {}) | |
| specs += list((poetry.get("dependencies") or {}).keys()) | |
| names = set() | |
| for spec in specs: | |
| m = re.match(r"\s*([A-Za-z0-9._-]+)", spec) | |
| if m and m.group(1).lower() != "python": | |
| names.add(m.group(1).lower().replace("_", "-")) | |
| return names | |
| ours = dep_names("pyproject.toml") | |
| theirs = dep_names("/tmp/gencheck/pyproject.toml") | |
| missing = sorted(theirs - ours) | |
| extra = sorted(ours - theirs) | |
| if extra: | |
| print(f"::notice::pyproject declares runtime deps the generator does not: {extra} (intentional project additions)") | |
| if missing: | |
| print("::error::The generated client now requires runtime dependencies missing from the hand-maintained pyproject.toml:") | |
| for name in missing: | |
| print(f" - {name}") | |
| print("Add them to [project].dependencies (and requirements.txt), then re-run.") | |
| sys.exit(1) | |
| print(f"No runtime dependency drift. Generator runtime deps: {sorted(theirs)}") | |
| PY | |
| rm -rf /tmp/gencheck | |
| - name: Patch generated __version__ to read from package metadata | |
| run: | | |
| python3 - <<'PY' | |
| import re, pathlib, sys | |
| p = pathlib.Path("hotdata/__init__.py") | |
| src = p.read_text() | |
| replacement = ( | |
| 'from importlib.metadata import PackageNotFoundError, version as _pkg_version\n' | |
| '\n' | |
| 'try:\n' | |
| ' __version__ = _pkg_version("hotdata")\n' | |
| 'except PackageNotFoundError: # running from a source checkout without install\n' | |
| ' __version__ = "0.0.0+unknown"\n' | |
| ) | |
| new, n = re.subn(r'^__version__ = "[^"]*"\n', replacement, src, count=1, flags=re.MULTILINE) | |
| if n != 1: | |
| sys.exit("Failed to patch __version__ line in hotdata/__init__.py") | |
| p.write_text(new) | |
| PY | |
| - name: Patch ApiClient close lifecycle | |
| run: python3 scripts/patch_api_client_close.py | |
| - name: Verify JWT-exchange code survived regeneration | |
| run: | | |
| python3 - <<'PY' | |
| import ast, pathlib, sys | |
| errors = [] | |
| # 1. The hand-written, regen-immune auth module must survive. | |
| if not pathlib.Path("hotdata/_auth.py").is_file(): | |
| errors.append("hotdata/_auth.py is missing (regen overwrote/dropped it)") | |
| config = pathlib.Path("hotdata/configuration.py") | |
| if not config.is_file(): | |
| errors.append("hotdata/configuration.py is missing") | |
| else: | |
| tree = ast.parse(config.read_text()) | |
| cls = next( | |
| (n for n in tree.body | |
| if isinstance(n, ast.ClassDef) and n.name == "Configuration"), | |
| None, | |
| ) | |
| if cls is None: | |
| errors.append("Configuration class not found in configuration.py") | |
| else: | |
| # 2. api_key must be a property (decorated getter), so every | |
| # request transparently exchanges for a fresh JWT. | |
| api_key_is_property = any( | |
| isinstance(n, ast.FunctionDef) | |
| and n.name == "api_key" | |
| and any( | |
| isinstance(d, ast.Name) and d.id == "property" | |
| for d in n.decorator_list | |
| ) | |
| for n in cls.body | |
| ) | |
| if not api_key_is_property: | |
| errors.append("Configuration.api_key is not a @property (template drift)") | |
| # 3. The token manager must be created eagerly in __init__ | |
| # (lazy creation has a concurrent-first-request race). | |
| init = next( | |
| (n for n in cls.body | |
| if isinstance(n, ast.FunctionDef) and n.name == "__init__"), | |
| None, | |
| ) | |
| init_src = ast.get_source_segment(config.read_text(), init) if init else "" | |
| if "self._token_manager = _TokenManager(" not in (init_src or ""): | |
| errors.append("eager self._token_manager assignment missing from __init__") | |
| # 4. __deepcopy__ must skip _token_manager (lock + PoolManager | |
| # are not deepcopy-able) and rebuild it. | |
| deepcopy = next( | |
| (n for n in cls.body | |
| if isinstance(n, ast.FunctionDef) and n.name == "__deepcopy__"), | |
| None, | |
| ) | |
| if deepcopy is None: | |
| errors.append("__deepcopy__ missing from Configuration") | |
| else: | |
| # Look for _token_manager as a real identifier/string in the | |
| # body (AST, so comments mentioning it don't count) — proves | |
| # the lock/PoolManager skip-and-rebuild actually survived. | |
| refs = any( | |
| (isinstance(n, ast.Constant) and n.value == "_token_manager") | |
| or (isinstance(n, ast.Attribute) and n.attr == "_token_manager") | |
| for n in ast.walk(deepcopy) | |
| ) | |
| if not refs: | |
| errors.append("__deepcopy__ does not skip/rebuild _token_manager") | |
| if errors: | |
| print("::error::JWT-exchange regen-safety check failed:") | |
| for e in errors: | |
| print(f" - {e}") | |
| sys.exit(1) | |
| print("JWT-exchange code survived regeneration: " | |
| "_auth.py present, api_key property, eager _token_manager, " | |
| "__deepcopy__ handling all intact.") | |
| PY | |
| - name: Clean up generated artifacts | |
| run: | | |
| rm -f openapi.yaml | |
| rm -f .github/workflows/python.yml | |
| - name: Verify built wheel installs and imports | |
| run: | | |
| python -m pip install --upgrade build | |
| python -m build | |
| python -m pip install dist/*.whl | |
| # cd away from the source tree so the import resolves against the installed wheel. | |
| cd /tmp && python -c "import hotdata; print(hotdata.__version__)" | |
| - name: Check integration test scenario parity | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| run: | | |
| curl -sS -f -L \ | |
| -H "Accept: application/vnd.github.v3.raw" \ | |
| -H "Authorization: Bearer $GH_TOKEN" \ | |
| https://api.github.com/repos/hotdata-dev/www.hotdata.dev/contents/api/test-scenarios.yaml \ | |
| -o test-scenarios.yaml | |
| pip install --quiet pyyaml | |
| python3 - <<'PY' | |
| import sys, pathlib, yaml | |
| scenarios = yaml.safe_load(pathlib.Path("test-scenarios.yaml").read_text())["scenarios"] | |
| missing = [] | |
| for s in scenarios: | |
| if "python" in (s.get("optional_for") or []): | |
| continue | |
| expected = pathlib.Path("tests/integration") / f"test_{s['name']}.py" | |
| if not expected.exists(): | |
| missing.append(str(expected)) | |
| if missing: | |
| print(f"::warning::sdk-python is missing tests for {len(missing)} scenarios after regen:") | |
| for m in missing: | |
| print(f" - {m}") | |
| else: | |
| print(f"All {len(scenarios)} scenarios have corresponding test files.") | |
| PY | |
| rm -f test-scenarios.yaml | |
| - name: Create PR | |
| id: cpr | |
| uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8 | |
| with: | |
| token: ${{ steps.app-token.outputs.token }} | |
| title: "${{ inputs.title || 'chore: regenerate client from updated OpenAPI spec' }}" | |
| branch: openapi-update-${{ github.run_id }} | |
| commit-message: "${{ inputs.title || 'chore: regenerate client from OpenAPI spec' }}" | |
| body: "${{ inputs.summary || 'Auto-generated from updated HotData OpenAPI spec.' }}" | |
| # Enable native auto-merge (squash). Branch protection on main gates the | |
| # merge on the test checks (scenario-parity, integration) plus the org | |
| # Claude review check and its approving review, so this only merges once | |
| # everything is green and Claude has approved. | |
| - name: Enable auto-merge | |
| if: steps.cpr.outputs.pull-request-number | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| run: | | |
| gh pr merge "${{ steps.cpr.outputs.pull-request-number }}" \ | |
| --repo "${{ github.repository }}" \ | |
| --squash --auto |