Regenerate Client #67
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.0.2 | |
| with: | |
| token: ${{ steps.app-token.outputs.token }} | |
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| with: | |
| toolchain: stable | |
| components: rustfmt | |
| - 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 | |
| # OpenAPI 3.1 lets a `$ref` carry sibling keywords (e.g. a per-branch | |
| # `description` on a `oneOf` member). That's valid, and the Python/TS | |
| # generators consume it fine, but the Rust backend NPEs on it | |
| # (AbstractRustCodegen.toModelName — reproduced through generator 7.22.0, | |
| # no upstream fix yet). Flatten `$ref`-with-siblings to pure refs in our | |
| # throwaway generation input only; the canonical www spec and the other | |
| # SDKs are untouched. See scripts/normalize-openapi.py. | |
| - name: Normalize spec for the Rust generator | |
| run: | | |
| python3 -c "import yaml" 2>/dev/null || pip3 install --quiet pyyaml | |
| python3 scripts/normalize-openapi.py openapi.yaml | |
| # Targeted clean: delete ONLY generator-owned subtrees. The hand-written | |
| # ergonomic layer (src/lib.rs, src/auth.rs, src/arrow.rs, src/client.rs) | |
| # is preserved here and additionally protected by .openapi-generator-ignore | |
| # so the generator re-emits src/apis and src/models in place without | |
| # clobbering the regen-immune modules or Cargo.toml. | |
| - name: Clean generated source | |
| run: rm -rf src/apis src/models docs | |
| # Read (do NOT bump) the current crate version. A regen is just a set of | |
| # changes; which release ships them — and the bump kind — is decided later | |
| # by the release tooling, not minted per regen. The version is still read | |
| # here only because the generator wants a packageVersion below; Cargo.toml | |
| # is ignore-protected so the generator never edits it. The default | |
| # user-agent is NOT pinned at regen time: configuration.mustache computes | |
| # it from CARGO_PKG_VERSION at compile time, so it can never lag (#69). | |
| - name: Read package version | |
| id: pkg | |
| run: | | |
| version=$(cargo metadata --no-deps --format-version 1 \ | |
| | jq -r '.packages[] | select(.name=="hotdata") | .version') | |
| echo "version=$version" >> "$GITHUB_OUTPUT" | |
| # Seed the regen notes under [Unreleased] so they accumulate there until a | |
| # release rolls them into a version. check-release.py is a no-op when the | |
| # version is unchanged, so a non-bumping regen PR still passes; the regen | |
| # still touches CHANGELOG.md, so check-release.yml runs as before. | |
| - name: Seed changelog entry under [Unreleased] | |
| env: | |
| TITLE: ${{ inputs.title }} | |
| run: | | |
| python3 - <<'PY' | |
| import os, pathlib, re | |
| title = (os.environ.get("TITLE") or "").strip() \ | |
| or "Regenerate the client from the updated Hotdata OpenAPI spec" | |
| bullet = f"- {title}" | |
| path = pathlib.Path("CHANGELOG.md") | |
| text = path.read_text() | |
| heading = re.search(r"^## \[Unreleased\][^\n]*\n", text, re.M) | |
| if not heading: | |
| raise SystemExit("CHANGELOG.md has no '## [Unreleased]' section to anchor the new entry") | |
| # Scope edits to the [Unreleased] body (up to the next '## [' or EOF). | |
| start = heading.end() | |
| nxt = re.search(r"^## \[", text[start:], re.M) | |
| end = start + nxt.start() if nxt else len(text) | |
| body = text[start:end] | |
| if any(line.strip() == bullet for line in body.splitlines()): | |
| print("CHANGELOG [Unreleased] already lists this entry; leaving it untouched.") | |
| raise SystemExit(0) | |
| changed = re.search(r"^### Changed[ \t]*\n", body, re.M) | |
| if changed: | |
| # Prepend the bullet to the existing ### Changed list. | |
| i = changed.end() | |
| while i < len(body) and body[i] == "\n": | |
| i += 1 | |
| body = body[:i] + bullet + "\n" + body[i:] | |
| else: | |
| # No ### Changed yet: open one right under the heading. | |
| body = "\n### Changed\n\n" + bullet + "\n\n" + body.lstrip("\n") | |
| path.write_text(text[:start] + body + text[end:]) | |
| print("Added regen entry under CHANGELOG [Unreleased].") | |
| PY | |
| - name: Generate client | |
| env: | |
| PACKAGE_VERSION: ${{ steps.pkg.outputs.version }} | |
| run: | | |
| # useChrono=false: 7.22.0 flipped the Rust generator's useChrono | |
| # default to true, which emits chrono::DateTime for date-time fields. | |
| # The crate has no chrono dependency and the ergonomic layer + tests | |
| # treat dates as String (as 7.20.0 generated them), so keep String. | |
| npx @openapitools/openapi-generator-cli generate \ | |
| -i openapi.yaml \ | |
| -g rust \ | |
| -o . \ | |
| -t .openapi-generator-templates \ | |
| --additional-properties=packageName=hotdata,packageVersion=$PACKAGE_VERSION,library=reqwest,supportAsync=true,useChrono=false \ | |
| --skip-validate-spec | |
| # The 7.22.0 generator emits compact, non-rustfmt output, which buries real | |
| # spec changes under formatting churn in every regen diff. Format only the | |
| # generated subtrees so the diff shows semantic changes; the hand-written | |
| # ergonomic layer is left to its own formatting (and is ignore-protected | |
| # from the generator anyway). | |
| - name: Format generated code | |
| run: | | |
| # Derive the edition from Cargo.toml so it can't drift from the crate | |
| # if the edition is ever bumped (same `cargo metadata` source as the | |
| # version read above). | |
| edition=$(cargo metadata --no-deps --format-version 1 \ | |
| | jq -r '.packages[] | select(.name=="hotdata") | .edition') | |
| find src/apis src/models -name '*.rs' -print0 \ | |
| | xargs -0 rustfmt --edition "$edition" | |
| - name: Clean up fetched spec | |
| run: rm -f openapi.yaml | |
| # Regen-safety guard (the Rust analog of sdk-python's AST check). Fails the | |
| # build loudly if the generator clobbered the hand-written ergonomic layer | |
| # or if the JWT/bearer template hooks were dropped or renamed by a future | |
| # generator version. Runs BEFORE cargo check so the failure is precise. | |
| - name: Verify ergonomic layer survived regen | |
| run: | | |
| set -euo pipefail | |
| fail=0 | |
| # 1. Hand-written, regen-immune modules must exist. | |
| for f in src/lib.rs src/auth.rs src/arrow.rs src/client.rs src/resources.rs src/field.rs src/status.rs src/http_log.rs; do | |
| if [ ! -f "$f" ]; then | |
| echo "::error::$f is missing (regen clobbered the ergonomic layer)" | |
| fail=1 | |
| fi | |
| done | |
| # 2. lib.rs must still wire the hand-written modules. | |
| for decl in 'pub mod auth;' 'pub mod client;' 'pub mod arrow;' 'pub mod resources;' 'pub mod field;' 'pub mod status;' 'pub mod http_log;'; do | |
| if ! grep -q "$decl" src/lib.rs; then | |
| echo "::error::src/lib.rs no longer declares '$decl'" | |
| fail=1 | |
| fi | |
| done | |
| # 3. lib.rs must still re-export the ergonomic surface. | |
| if ! grep -Eq 'pub use (crate::)?auth::' src/lib.rs; then | |
| echo "::error::src/lib.rs no longer re-exports the auth surface" | |
| fail=1 | |
| fi | |
| if ! grep -Eq 'pub use (crate::)?client::' src/lib.rs; then | |
| echo "::error::src/lib.rs no longer re-exports the client surface" | |
| fail=1 | |
| fi | |
| # 4. The JWT/bearer template hooks must have survived the generator. | |
| # configuration.mustache emits resolve_bearer_token on Configuration; | |
| # api.mustache calls it at every bearer-auth site. | |
| if ! grep -q 'resolve_bearer_token' src/apis/configuration.rs; then | |
| echo "::error::resolve_bearer_token missing from generated configuration.rs (configuration.mustache drift)" | |
| fail=1 | |
| fi | |
| if ! grep -rq 'resolve_bearer_token().await' src/apis/; then | |
| echo "::error::resolve_bearer_token().await missing from generated apis (api.mustache bearer hook drift)" | |
| fail=1 | |
| fi | |
| # 5. The request/response debug-logging hooks must have survived the | |
| # generator. api.mustache emits crate::http_log::log_request + | |
| # log_response_status/_body at every op (issue #135). | |
| if ! grep -rq 'crate::http_log::log_request' src/apis/; then | |
| echo "::error::http_log::log_request missing from generated apis (api.mustache debug-logging hook drift)" | |
| fail=1 | |
| fi | |
| if ! grep -rq 'crate::http_log::log_response_status' src/apis/; then | |
| echo "::error::http_log::log_response_status missing from generated apis (api.mustache debug-logging hook drift)" | |
| fail=1 | |
| fi | |
| if [ "$fail" -ne 0 ]; then | |
| echo "::error::Regen-safety check failed: the ergonomic layer or auth/logging hooks did not survive regeneration." | |
| exit 1 | |
| fi | |
| echo "Ergonomic layer survived regeneration: hand-written modules, lib.rs wiring, and JWT/bearer + debug-logging hooks all intact." | |
| # NOTE: regeneration deliberately does NOT compile the crate. A compile | |
| # failure here used to abort the job before "Create PR", so a regen that | |
| # produced valid-but-not-yet-wired output (e.g. a spec change that orphans | |
| # part of the hand-written ergonomic layer) failed silently with no PR to | |
| # act on. The PR is the artifact we want: integration-tests.yml runs | |
| # `cargo test --all-features` on every PR to main, so any breakage shows | |
| # up loudly as red CI on the filed PR instead of a buried workflow run. | |
| # Soft parity warning: surface scenarios that still lack a tests/<name>.rs. | |
| # Non-fatal during the per-scenario rollout (rust is exempt until its tests | |
| # land); the enforcing gate lives in integration-tests.yml. | |
| - 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 | |
| python3 - <<'PY' | |
| import sys, pathlib, re | |
| text = pathlib.Path("test-scenarios.yaml").read_text() | |
| # Minimal stdlib parse: walk "- name:" blocks, capture optional_for. | |
| missing = [] | |
| total = 0 | |
| name = None | |
| optional = [] | |
| def flush(name, optional): | |
| if name is None: | |
| return | |
| if "rust" in optional: | |
| return | |
| expected = pathlib.Path("tests") / f"{name}.rs" | |
| if not expected.exists(): | |
| missing.append(str(expected)) | |
| for line in text.splitlines(): | |
| m = re.match(r"\s*-\s+name:\s*(\S+)", line) | |
| if m: | |
| flush(name, optional) | |
| total += 1 | |
| name = m.group(1).strip().strip('"\'') | |
| optional = [] | |
| continue | |
| mo = re.match(r"\s*optional_for:\s*\[(.*)\]", line) | |
| if mo: | |
| optional = [x.strip().strip('"\'') for x in mo.group(1).split(",") if x.strip()] | |
| flush(name, optional) | |
| if missing: | |
| print(f"::warning::sdk-rust is missing tests for {len(missing)} scenarios after regen:") | |
| for m in missing: | |
| print(f" - {m}") | |
| else: | |
| print(f"All {total} scenarios have corresponding test files (or are exempt for rust).") | |
| PY | |
| rm -f test-scenarios.yaml | |
| - name: Create PR | |
| id: cpr | |
| uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 | |
| 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 |