Skip to content

Commit be9c2f4

Browse files
committed
Final round of improvements (#3)
1 parent c61c7ef commit be9c2f4

1,590 files changed

Lines changed: 18688 additions & 1431 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/sync.yml‎

Lines changed: 109 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,11 @@ jobs:
4949
with:
5050
fetch-depth: 0 # need full history + tags to read the last version
5151

52-
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
5352

5453
# Goes to a scratch path because oasdiff needs both versions to compare;
5554
# writing straight over openapi.json would lose the old one.
5655
- name: Fetch latest public schema
57-
run: curl -sfS https://api.incident.io/v1/openapiV3.json -o openapi.json.new
56+
run: make fetch OUT=openapi.json.new
5857

5958
- name: Exit early if schema unchanged
6059
id: changed
@@ -66,6 +65,23 @@ jobs:
6665
echo "changed=true" >> "$GITHUB_OUTPUT"
6766
fi
6867
68+
- if: steps.changed.outputs.changed == 'true'
69+
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
70+
with:
71+
# The default key hashes pyproject.toml, which every release rewrites
72+
# with a new version, so it would miss every time. The pins that
73+
# actually decide what gets downloaded live in the Makefile.
74+
cache-dependency-glob: Makefile
75+
# mypy over 1895 files is the slowest part of verify: ~6s cold against
76+
# ~0.2s warm. make generate rewrites every file, but mypy falls back to
77+
# the source hash, and most models are untouched between releases.
78+
- if: steps.changed.outputs.changed == 'true'
79+
uses: actions/cache@v4
80+
with:
81+
path: .mypy_cache
82+
key: mypy-${{ hashFiles('Makefile') }}-${{ github.sha }}
83+
restore-keys: mypy-${{ hashFiles('Makefile') }}-
84+
6985
- name: Check for breaking changes
7086
if: steps.changed.outputs.changed == 'true'
7187
id: breaking
@@ -80,9 +96,31 @@ jobs:
8096
exit 0
8197
fi
8298
83-
report="$(/tmp/oasdiff breaking openapi.json openapi.json.new --fail-on ERR 2>&1)" && rc=0 || rc=$?
99+
# oasdiff-severity.txt adjusts four rules in both directions; it
100+
# takes no comments of its own.
101+
#
102+
# Down to WARN: an added response enum value, because our generated
103+
# enums accept unknown values (templates/str_enum.py.jinja), and a
104+
# tightened request minimum, because the generator emits no range
105+
# validation so the client cannot behave differently. Left at ERR
106+
# these halt about a quarter of real schema changes — over 49
107+
# consecutive changes from sdk-go's history, 12 would have stopped.
108+
#
109+
# Up to ERR: a removed operationId or component schema. Neither
110+
# changes the wire contract, so oasdiff rates them info, but both
111+
# rename something consumers import.
112+
report="$(/tmp/oasdiff breaking openapi.json openapi.json.new \
113+
--severity-levels oasdiff-severity.txt --fail-on ERR 2>&1)" && rc=0 || rc=$?
84114
echo "$report"
85115
116+
# 1 means it found breaking changes. Anything else means it couldn't
117+
# do the comparison at all — a truncated fetch, an HTML error page —
118+
# which is not a breaking change and mustn't be filed as one.
119+
if [ "$rc" -ne 0 ] && [ "$rc" -ne 1 ]; then
120+
echo "::error::oasdiff exited $rc without comparing the schemas."
121+
exit 1
122+
fi
123+
86124
if [ "$rc" -ne 0 ]; then
87125
echo "breaking=true" >> "$GITHUB_OUTPUT"
88126
{ echo "report<<OASDIFF_EOF"; echo "$report"; echo "OASDIFF_EOF"; } >> "$GITHUB_OUTPUT"
@@ -123,24 +161,20 @@ jobs:
123161
exit 1
124162
fi
125163
164+
# Created here rather than assumed: gh issue create fails outright on
165+
# a label that doesn't exist, and an unlabelled fallback would be
166+
# invisible to the dedup above, filing one issue an hour forever.
167+
gh label create breaking-change \
168+
--description "Schema changed incompatibly; release halted" \
169+
--color B60205 2>/dev/null || true
170+
126171
gh issue create \
127172
--title "Breaking API change detected — manual release required" \
128173
--body "$body" \
129-
--label "breaking-change" || \
130-
gh issue create \
131-
--title "Breaking API change detected — manual release required" \
132-
--body "$body"
174+
--label "breaking-change"
133175
echo "::error::Breaking change detected; opened an issue and stopped."
134176
exit 1
135177
136-
- name: Regenerate and verify
137-
if: steps.changed.outputs.changed == 'true'
138-
run: |
139-
set -euo pipefail
140-
mv openapi.json.new openapi.json
141-
make generate
142-
make verify
143-
144178
# Scheduled runs only ever bump minor, since anything breaking has already
145179
# stopped the run. Patch is for hand-fixes, major for a human dispatch.
146180
- name: Decide next version
@@ -149,29 +183,79 @@ jobs:
149183
run: |
150184
set -euo pipefail
151185
BUMP="${{ inputs.bump || 'minor' }}"
152-
BASE="$(git describe --tags --abbrev=0 2>/dev/null || true)"
186+
# Highest version, not `git describe`'s nearest-by-commit-distance:
187+
# a hand-cut patch tag would otherwise make us recompute a version
188+
# that already exists. The pattern also skips any non-semver tag,
189+
# which awk would silently turn into v1.0.0.
190+
BASE="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -1)"
153191
154192
if [ -z "$BASE" ]; then
155193
NEXT="v1.0.0"
156194
elif [ "$BUMP" = "major" ]; then
157195
NEXT="$(echo "${BASE#v}" | awk -F. '{printf "v%d.0.0", $1+1}')"
158196
else
159-
NEXT="$(echo "${BASE#v}" | awk -F. '{printf "v%s.%d.0", $1, $2+1}')"
197+
NEXT="$(echo "${BASE#v}" | awk -F. '{printf "v%d.%d.0", $1, $2+1}')"
198+
fi
199+
200+
if git rev-parse -q --verify "refs/tags/$NEXT" >/dev/null; then
201+
echo "::error::$NEXT already exists. A previous run tagged without"
202+
echo "::error::publishing, or a tag was cut by hand. Resolve before releasing."
203+
exit 1
160204
fi
161205
162206
echo "Next version: $NEXT (base: ${BASE:-none}, bump: $BUMP)"
163207
echo "next=$NEXT" >> "$GITHUB_OUTPUT"
164208
165-
- name: Build the release
209+
# Before make verify, so the wheel we check is the wheel we publish.
210+
- name: Set the release version
166211
if: steps.changed.outputs.changed == 'true'
167212
env:
168213
NEXT: ${{ steps.version.outputs.next }}
169214
run: |
170215
set -euo pipefail
171-
rm -rf dist
172216
sed -i -E "0,/^version = /s/^version = .*/version = \"${NEXT#v}\"/" pyproject.toml
173217
grep -m1 '^version = ' pyproject.toml
174-
uv build
218+
219+
- name: Regenerate and check
220+
if: steps.changed.outputs.changed == 'true'
221+
run: |
222+
set -euo pipefail
223+
mv openapi.json.new openapi.json
224+
make generate
225+
# make test depends on make verify: builds the wheel, imports every
226+
# module, type-checks, checks the API surface, then runs the tests.
227+
# The generated code changes on every release, which is exactly when
228+
# behaviour needs checking.
229+
make test
230+
231+
# Every step above this point fails without committing anything, so the
232+
# next run sees the same schema diff and fails identically. Left alone
233+
# that is a red cron every hour and no other signal.
234+
- name: Report a stuck release
235+
if: failure() && steps.changed.outputs.changed == 'true' && steps.breaking.outputs.breaking != 'true'
236+
env:
237+
GH_TOKEN: ${{ github.token }}
238+
run: |
239+
set -euo pipefail
240+
title="Release is stuck — regeneration or verification is failing"
241+
gh label create release-stuck \
242+
--description "The hourly sync cannot complete and will retry forever" \
243+
--color B60205 2>/dev/null || true
244+
245+
if gh issue list --label release-stuck --state open --limit 1 \
246+
--json number --jq '.[0].number // empty' | grep -q .; then
247+
echo "Already reported."
248+
exit 0
249+
fi
250+
251+
gh issue create --title "$title" --label release-stuck --body "$(cat <<EOF
252+
The hourly sync failed after detecting a schema change, before committing anything. The next run will see the same diff and fail the same way, so this repeats hourly until someone looks.
253+
254+
Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID
255+
256+
Likely causes: the generator rejected the new schema, a post-generation fix in \`scripts/\` stopped matching, or verification failed.
257+
EOF
258+
)"
175259
176260
- name: Report what a dry run would have done
177261
if: steps.changed.outputs.changed == 'true' && inputs.dry_run
@@ -198,12 +282,15 @@ jobs:
198282
set -euo pipefail
199283
git config user.name "incident-io-bot"
200284
git config user.email "github-actions[bot]@users.noreply.github.com"
201-
git add openapi.json pyproject.toml incident_io
285+
git add openapi.json pyproject.toml incident_io api-surface.txt
202286
git commit -m "Update to schema as of $(date -u +%Y-%m-%dT%H:%M:%SZ)
203287
204288
Regenerated from https://api.incident.io/v1/openapiV3.json"
205289
git tag "$NEXT"
206-
git push origin HEAD --tags
290+
# --atomic: otherwise the tag can land while the branch push is
291+
# refused, and every later run recomputes this version and dies on
292+
# "tag already exists".
293+
git push --atomic origin HEAD "refs/tags/$NEXT"
207294
gh release create "$NEXT" --generate-notes --title "$NEXT"
208295
209296
# Has to live in this job: id-token is job-scoped, and a tag pushed with

‎.github/workflows/test.yml‎

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,22 @@ jobs:
1111
steps:
1212
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
1313
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
14-
# Builds the wheel, installs it, byte-compiles the tree and imports every
15-
# module. See scripts/verify_import.py.
16-
- run: make verify
17-
# Behavioural checks against a stub transport, so they need no API key.
18-
# scripts/smoke_test.py covers the live API and is run by hand.
19-
- run: uv run --group dev pytest tests/ -q
14+
with:
15+
# The default key hashes pyproject.toml, which every release rewrites
16+
# with a new version, so it would miss every time. The pins that
17+
# actually decide what gets downloaded live in the Makefile.
18+
cache-dependency-glob: Makefile
19+
# mypy over 1895 files is the slowest part of verify: ~6s cold against
20+
# ~0.2s warm. make generate rewrites every file, but mypy falls back to
21+
# the source hash, and most models are untouched between releases.
22+
- uses: actions/cache@v4
23+
with:
24+
path: .mypy_cache
25+
key: mypy-${{ hashFiles('Makefile') }}-${{ github.sha }}
26+
restore-keys: mypy-${{ hashFiles('Makefile') }}-
27+
# `test` depends on `verify`, so this builds the wheel, imports every
28+
# module, type-checks, checks the API surface and runs the tests.
29+
- run: make test
2030

2131
# Fail if the committed client is out of date with the committed schema — i.e.
2232
# someone changed openapi.json without re-running the generator. Regenerates
@@ -27,17 +37,18 @@ jobs:
2737
steps:
2838
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
2939
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
30-
# The generator's post_hooks shell out to ruff. Without it on PATH they
31-
# are skipped with a warning, and --fail-on-warning then fails the build.
32-
# uv installs tools into ~/.local/bin, which isn't on a runner's PATH.
33-
- run: |
34-
uv tool install ruff
35-
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
40+
with:
41+
cache-dependency-glob: Makefile
42+
# No ruff step: openapi-python-client depends on ruff and uvx puts the
43+
# tool environment's bin first, so the generator's post_hooks always find
44+
# its own copy rather than anything installed here.
3645
- run: make generate
3746
- name: Check for drift
3847
run: |
39-
if ! git diff --quiet; then
48+
# --porcelain, not `git diff --quiet`: the latter ignores untracked
49+
# files, so a new endpoint module would pass unnoticed.
50+
if [ -n "$(git status --porcelain)" ]; then
4051
echo "::error::Generated client is out of date. Run 'make generate' and commit the result."
41-
git diff --stat
52+
git status --porcelain
4253
exit 1
4354
fi

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
.ruff_cache/
2+
.mypy_cache/
3+
.pytest_cache/
24
dist/
35
build/
46
*.egg-info/

‎Makefile‎

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,76 @@
22
# which would reshape our public API. Upgrade by hand and read the diff.
33
OPC_VERSION := 0.29.1
44

5+
# The generator declares ruff>=0.2 with no upper bound and runs it as a post
6+
# hook, so an unpinned resolve reformats all 1895 files the day ruff changes
7+
# its formatter: drift goes red on every unrelated PR, and the next release
8+
# commits a whole-tree reformat labelled as a schema change.
9+
RUFF_VERSION := 0.16.8
10+
511
PACKAGE := incident_io
612
SCHEMA_URL := https://api.incident.io/v1/openapiV3.json
713

8-
.PHONY: fetch generate verify clean
14+
.PHONY: fetch generate verify test surface clean
15+
16+
# -L because without it a redirect is a silent success writing zero bytes,
17+
# which parses as an empty schema. OUT lets the release workflow fetch to a
18+
# scratch path so it still has the previous schema to diff against.
19+
OUT ?= openapi.json
920

1021
fetch:
11-
curl -sfS $(SCHEMA_URL) -o openapi.json
22+
curl -sfSL $(SCHEMA_URL) -o $(OUT)
1223

1324
# --meta none keeps the generator to the package itself. Any other value has it
1425
# write pyproject.toml and README.md too, overwriting ours.
1526
generate: openapi.json
1627
rm -rf $(PACKAGE)
17-
uvx --from openapi-python-client==$(OPC_VERSION) openapi-python-client generate \
28+
uvx --from openapi-python-client==$(OPC_VERSION) --with ruff==$(RUFF_VERSION) \
29+
openapi-python-client generate \
1830
--path openapi.json \
1931
--output-path $(PACKAGE) \
2032
--meta none \
2133
--config config.yml \
34+
--custom-template-path templates \
2235
--overwrite \
2336
--fail-on-warning
37+
python3 scripts/fix_generated.py $(PACKAGE) openapi.json
2438
python3 scripts/mark_deprecated.py openapi.json $(PACKAGE)
39+
# The generator runs ruff as a post hook, but these two scripts run after
40+
# it, so what they write is unformatted. Pinned to the same version the
41+
# generator uses, or the two would disagree and each reformat the other's
42+
# output.
43+
uvx ruff==$(RUFF_VERSION) format --quiet $(PACKAGE)
2544
# Committed too, but recreated here because generate removes the package
2645
# first. Without it type checkers ignore every annotation in the SDK.
2746
touch $(PACKAGE)/py.typed
2847

48+
# Pinned: verify gates the release, so a new check in a mypy release would
49+
# halt the hourly pipeline rather than fail a pull request.
50+
MYPY_VERSION := 2.3.1
51+
2952
# Python has no compile step, so we install the wheel and exercise it. See
3053
# scripts/verify_import.py for why importing the package alone isn't enough.
3154
verify:
55+
rm -rf dist
3256
uv build
3357
uv venv --clear .venv-verify
34-
uv pip install --quiet --python .venv-verify dist/*.whl
35-
.venv-verify/bin/python scripts/verify_import.py
58+
uv pip install --quiet --python .venv-verify dist/*.whl mypy==$(MYPY_VERSION) pytest pytest-asyncio
59+
.venv-verify/bin/python scripts/verify_import.py api-surface.txt
60+
# We ship py.typed, so consumers' type checkers read these annotations. Run
61+
# against the installed package rather than the source tree: on the tree,
62+
# mypy resolves the generated `Response` to httpx's and reports thousands of
63+
# errors that no consumer ever sees.
64+
.venv-verify/bin/mypy -p incident_io
65+
66+
# Depends on verify so the tests run against the installed wheel rather than
67+
# the source tree, in the environment verify already built.
68+
test: verify
69+
.venv-verify/bin/python -m pytest tests/ -q
70+
71+
# Accept the current surface as the new baseline. Run after a deliberate
72+
# breaking change, alongside the major version bump.
73+
surface: verify
74+
.venv-verify/bin/python scripts/api_surface.py write $(PACKAGE) api-surface.txt
3675

3776
clean:
38-
rm -rf $(PACKAGE) dist build .venv-verify .ruff_cache
77+
rm -rf $(PACKAGE) dist build .venv-verify .ruff_cache .mypy_cache

0 commit comments

Comments
 (0)