-
Notifications
You must be signed in to change notification settings - Fork 0
337 lines (305 loc) · 15.1 KB
/
Copy pathregenerate.yml
File metadata and controls
337 lines (305 loc) · 15.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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
# Remove the generator-owned subtrees before regenerating so endpoints and
# models dropped from the spec don't linger as orphaned files. The package
# lives in hotdata/, so the previous `rm -rf src/` was a no-op and removed
# APIs (e.g. the sandbox endpoints) kept shipping as dead modules. The
# hand-written modules (hotdata/arrow.py, hotdata/uploads.py) live at the
# package root and are untouched; the integration tests in tests/ (plural)
# are distinct from the generated test/ (singular) unit tests.
#
# A few hand-written files DO live inside these subtrees (e.g.
# test/test_api_client_close.py). They're listed in .openapi-generator-ignore
# — the source of truth for "hand-maintained, don't touch" — so restore any
# ignored path under a cleaned subtree after the wipe. The generator already
# won't overwrite them; this makes the clean step honor the same list so a
# regen never deletes hand-maintained code.
- name: Clean generated source
run: |
rm -rf hotdata/api hotdata/models docs test
grep -vE '^[[:space:]]*(#|$)' .openapi-generator-ignore | while IFS= read -r path; do
case "$path" in
hotdata/api/*|hotdata/models/*|docs/*|test/*) git checkout -- "$path" ;;
esac
done
# The version bump is intentionally NOT done here. A regen is just a set of
# changes; which release they ship in (and the bump kind) is decided later
# by scripts/release.sh prepare. Seed the regen notes under [Unreleased] so
# they accumulate there until a release rolls them into a version. This
# avoids minting a dated version section per regen that may never publish.
- 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
# No packageVersion: pyproject.toml is hand-maintained and the generator
# doesn't stamp it, while __version__ and the SDK version string both read
# from installed package metadata — so the generator's packageVersion never
# reaches committed 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,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: Patch default client exports (enhanced query/results)
run: python3 scripts/patch_query_exports.py
- name: Patch transport request defaults (Accept-Encoding, User-Agent)
run: python3 scripts/patch_request_defaults.py
# The API-token -> JWT key exchange is deprecated: the configured API token
# is the bearer credential and goes on the wire verbatim. This check is the
# inverse of the old "did the exchange survive?" guard — it fails if the
# exchange machinery reappears (a stale template, a bad merge) so a regen
# can't quietly start minting JWTs again.
- name: Verify API-token auth survived regeneration
run: |
python3 - <<'PY'
import ast, pathlib, sys
errors = []
# 1. The exchange module must stay deleted.
if pathlib.Path("hotdata/_auth.py").is_file():
errors.append("hotdata/_auth.py is back (JWT exchange was removed)")
config = pathlib.Path("hotdata/configuration.py")
if not config.is_file():
errors.append("hotdata/configuration.py is missing")
else:
source = config.read_text()
tree = ast.parse(source)
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. No exchange machinery anywhere in the generated module.
for needle in ("_TokenManager", "_token_manager", "/v1/auth/jwt"):
if needle in source:
errors.append(f"configuration.py references {needle}")
# 3. api_key must NOT be a property: a getter is what turned
# every read into a mint.
if 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
):
errors.append("Configuration.api_key is a @property again (template drift)")
# 4. api_key must be assigned as a plain attribute in __init__,
# otherwise the credential never reaches auth_settings().
init = next(
(n for n in cls.body
if isinstance(n, ast.FunctionDef) and n.name == "__init__"),
None,
)
init_src = ast.get_source_segment(source, init) if init else ""
if "self.api_key = api_key" not in (init_src or ""):
errors.append("self.api_key = api_key assignment missing from __init__")
if errors:
print("::error::API-token auth regen-safety check failed:")
for e in errors:
print(f" - {e}")
sys.exit(1)
print("API-token auth survived regeneration: no _auth.py, no token "
"manager, api_key is a plain attribute set in __init__.")
PY
- name: Clean up generated artifacts
run: |
rm -f openapi.yaml
rm -f .github/workflows/python.yml
# NOTE: regeneration deliberately does NOT build/import the package. A
# failure here used to abort the job before "Create PR", so a regen that
# produced valid-but-not-yet-wired output failed silently with no PR to act
# on. The PR is the artifact we want, and breakage surfaces on it as red CI:
# integration-tests.yml installs the package (`pip install -e .`) and runs
# pytest, and check-release.yml builds + installs + imports the wheel on
# every PR touching pyproject.toml/CHANGELOG.md (a regen seeds a [Unreleased]
# changelog entry, so it always touches CHANGELOG.md). Auto-merge is gated on
# those checks, so a broken regen can't merge.
- 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@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # 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