@@ -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
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
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
0 commit comments