Skip to content

Regenerate Client

Regenerate Client #67

Workflow file for this run

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