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 with:
client-id: Iv23liKBX2RYMoZIYuKa
private-key: ${{ secrets.HOTDATA_AUTOMATION_PRIVATE_KEY }}
owner: hotdata-dev
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with:
token: ${{ steps.app-token.outputs.token }}
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 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
- 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
- name: Clean generated source
run: rm -rf src/apis src/models docs
- 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"
- 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 \
--http-user-agent "hotdata-rust/${PACKAGE_VERSION}" \
--skip-validate-spec
- 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
- 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."
- 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 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.' }}"