Repository navigation
318 lines (287 loc) · 13.1 KB
/
Copy pathsync.yml
File metadata and controls
318 lines (287 loc) · 13.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
name: sync
# Runs hourly once enabled. When the schema hasn't changed the job exits in
# seconds, which is what makes that affordable.
#
# Do not rename this file. PyPI's trusted-publishing binding is registered
# against the repo and this filename, so a rename breaks publishing silently.
on:
# Avoid minute 0, which is GitHub's most contended scheduling slot.
schedule:
- cron: "17 * * * *" # hourly
workflow_dispatch:
inputs:
# auto: major if oasdiff or the API surface check finds a break,
# otherwise minor. major forces one for a break neither gate sees.
# There is no minor override: a detected break never ships as a minor.
bump:
description: "Which part of the version to bump"
type: choice
options: [auto, major]
default: auto
dry_run:
description: "Regenerate and build, but don't tag, release or publish"
type: boolean
default: false
permissions:
contents: write # commit + tag + create release
issues: write # open an issue when a release is stuck
id-token: write # PyPI trusted publishing (job-scoped, hence one job)
concurrency:
group: sync
cancel-in-progress: false
env:
# Pinned deliberately: oasdiff decides whether a release is a major.
OASDIFF_VERSION: "1.32.1"
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # need full history + tags to read the last version
# Goes to a scratch path because oasdiff needs both versions to compare;
# writing straight over openapi.json would lose the old one.
- name: Fetch latest public schema
run: make fetch OUT=openapi.json.new
- name: Exit early if schema unchanged
id: changed
run: |
if diff -q openapi.json openapi.json.new >/dev/null 2>&1; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Schema unchanged; nothing to do."
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- if: steps.changed.outputs.changed == 'true'
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
# The default key hashes pyproject.toml, which every release rewrites
# with a new version, so it would miss every time. The pins that
# actually decide what gets downloaded live in the Makefile.
cache-dependency-glob: Makefile
# mypy over 1895 files is the slowest part of verify: ~6s cold against
# ~0.2s warm. make generate rewrites every file, but mypy falls back to
# the source hash, and most models are untouched between releases.
- if: steps.changed.outputs.changed == 'true'
uses: actions/cache@v4
with:
path: .mypy_cache
key: mypy-${{ hashFiles('Makefile') }}-${{ github.sha }}
restore-keys: mypy-${{ hashFiles('Makefile') }}-
- name: Check for breaking changes
if: steps.changed.outputs.changed == 'true'
id: breaking
run: |
set -euo pipefail
curl -sfSL "https://github.com/oasdiff/oasdiff/releases/download/v${OASDIFF_VERSION}/oasdiff_${OASDIFF_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C /tmp oasdiff
# First release: nothing to compare against.
if [ ! -f openapi.json ]; then
echo "breaking=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# oasdiff-severity.txt adjusts four rules in both directions; it
# takes no comments of its own.
#
# Down to WARN: an added response enum value, because our generated
# enums accept unknown values (templates/str_enum.py.jinja), and a
# tightened request minimum, because the generator emits no range
# validation so the client cannot behave differently. Left at ERR
# these halt about a quarter of real schema changes — over 49
# consecutive changes from sdk-go's history, 12 would have stopped.
#
# Up to ERR: a removed operationId or component schema. Neither
# changes the wire contract, so oasdiff rates them info, but both
# rename something consumers import.
report="$(/tmp/oasdiff breaking openapi.json openapi.json.new \
--severity-levels oasdiff-severity.txt --fail-on ERR 2>&1)" && rc=0 || rc=$?
echo "$report"
# 1 means it found breaking changes. Anything else means it couldn't
# do the comparison at all — a truncated fetch, an HTML error page —
# which is not a breaking change and mustn't be released as one.
if [ "$rc" -ne 0 ] && [ "$rc" -ne 1 ]; then
echo "::error::oasdiff exited $rc without comparing the schemas."
exit 1
fi
# A break does not stop the run: it makes this release a major.
# The report goes into the release notes so the major explains
# itself.
if [ "$rc" -ne 0 ]; then
echo "breaking=true" >> "$GITHUB_OUTPUT"
{
echo "### oasdiff: breaking changes to the API"
echo
echo '```'
echo "$report"
echo '```'
echo
} >> /tmp/breaks.md
else
echo "breaking=false" >> "$GITHUB_OUTPUT"
fi
# Before the version is chosen, because a removal here makes this
# release a major.
- name: Regenerate and check the API surface
if: steps.changed.outputs.changed == 'true'
id: surface
run: |
set -euo pipefail
mv openapi.json.new openapi.json
make generate
make install
# Exit 3 is removals only; anything else is a broken build and
# stops the run.
.venv-verify/bin/python scripts/verify_import.py api-surface.txt 2>/tmp/surface.txt && rc=0 || rc=$?
cat /tmp/surface.txt >&2
if [ "$rc" -eq 3 ]; then
echo "removed=true" >> "$GITHUB_OUTPUT"
{
echo "### Removed from the Python API"
echo
echo '```'
grep '^ ' /tmp/surface.txt
echo '```'
echo
} >> /tmp/breaks.md
elif [ "$rc" -ne 0 ]; then
exit "$rc"
else
echo "removed=false" >> "$GITHUB_OUTPUT"
fi
# After the surface check, because it is one of the two signals. Patch
# is for hand-fixes and never cut here.
- name: Decide next version
if: steps.changed.outputs.changed == 'true'
id: version
env:
BUMP_INPUT: ${{ inputs.bump || 'auto' }}
BREAKING: ${{ steps.breaking.outputs.breaking }}
REMOVED: ${{ steps.surface.outputs.removed }}
run: |
set -euo pipefail
if [ "$BUMP_INPUT" = "major" ] || [ "$BREAKING" = "true" ] || [ "$REMOVED" = "true" ]; then
BUMP=major
else
BUMP=minor
fi
# Highest version, not `git describe`'s nearest-by-commit-distance:
# a hand-cut patch tag would otherwise make us recompute a version
# that already exists. The pattern also skips any non-semver tag,
# which awk would silently turn into v1.0.0.
BASE="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -1)"
if [ -z "$BASE" ]; then
NEXT="v1.0.0"
elif [ "$BUMP" = "major" ]; then
NEXT="$(echo "${BASE#v}" | awk -F. '{printf "v%d.0.0", $1+1}')"
else
NEXT="$(echo "${BASE#v}" | awk -F. '{printf "v%d.%d.0", $1, $2+1}')"
fi
if git rev-parse -q --verify "refs/tags/$NEXT" >/dev/null; then
echo "::error::$NEXT already exists. A previous run tagged without"
echo "::error::publishing, or a tag was cut by hand. Resolve before releasing."
exit 1
fi
echo "Next version: $NEXT (base: ${BASE:-none}, bump: $BUMP, oasdiff break: $BREAKING, surface removals: $REMOVED)"
echo "next=$NEXT" >> "$GITHUB_OUTPUT"
# Release notes: the breaks, if any, above GitHub's generated notes.
if [ "$BUMP" = "major" ]; then
{
echo "## Breaking changes"
echo
if [ -f /tmp/breaks.md ]; then
cat /tmp/breaks.md
else
echo "Released as a major by hand (bump: major); neither gate reported a break."
fi
} > /tmp/release-notes.md
else
: > /tmp/release-notes.md
fi
# Before make test, so the wheel we check is the wheel we publish.
- name: Set the release version
if: steps.changed.outputs.changed == 'true'
env:
NEXT: ${{ steps.version.outputs.next }}
run: |
set -euo pipefail
sed -i -E "0,/^version = /s/^version = .*/version = \"${NEXT#v}\"/" pyproject.toml
grep -m1 '^version = ' pyproject.toml
- name: Record the surface and test
if: steps.changed.outputs.changed == 'true'
run: |
set -euo pipefail
# Rewritten on every release, removals and additions included, so
# the next release checks against what this one ships. Before the
# tests, so the check inside make verify runs against it.
make surface
# make test depends on make verify: builds the wheel, imports every
# module, type-checks, checks the API surface, then runs the tests.
# The generated code changes on every release, which is exactly when
# behaviour needs checking.
make test
# Every step above this point fails without committing anything, so the
# next run sees the same schema diff and fails identically. Left alone
# that is a red cron every hour and no other signal.
- name: Report a stuck release
if: failure() && steps.changed.outputs.changed == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
title="Release is stuck — regeneration or verification is failing"
gh label create release-stuck \
--description "The hourly sync cannot complete and will retry forever" \
--color B60205 2>/dev/null || true
if gh issue list --label release-stuck --state open --limit 1 \
--json number --jq '.[0].number // empty' | grep -q .; then
echo "Already reported."
exit 0
fi
gh issue create --title "$title" --label release-stuck --body "$(cat <<EOF
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.
Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID
Likely causes: the generator rejected the new schema, a post-generation fix in \`scripts/\` stopped matching, or verification failed. Removals from the API surface do not land here: they make the release a major.
EOF
)"
- name: Report what a dry run would have done
if: steps.changed.outputs.changed == 'true' && inputs.dry_run
env:
NEXT: ${{ steps.version.outputs.next }}
run: |
{
echo "### Dry run — nothing was tagged or published"
echo
echo "Would have released **$NEXT**."
echo
cat /tmp/release-notes.md
echo
echo "Built artefacts:"
echo '```'
ls -1 dist
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Commit, tag and release
if: steps.changed.outputs.changed == 'true' && !inputs.dry_run
env:
GH_TOKEN: ${{ github.token }}
NEXT: ${{ steps.version.outputs.next }}
run: |
set -euo pipefail
git config user.name "incident-io-bot"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add openapi.json pyproject.toml incident_io api-surface.txt
git commit -m "Update to schema as of $(date -u +%Y-%m-%dT%H:%M:%SZ)
Regenerated from https://api.incident.io/v1/openapiV3.json"
git tag "$NEXT"
# --atomic: otherwise the tag can land while the branch push is
# refused, and every later run recomputes this version and dies on
# "tag already exists".
git push --atomic origin HEAD "refs/tags/$NEXT"
# --notes-file with --generate-notes puts the file above the
# generated notes. The file is empty on a minor.
gh release create "$NEXT" --generate-notes --notes-file /tmp/release-notes.md --title "$NEXT"
# Has to live in this job: id-token is job-scoped, and a tag pushed with
# GITHUB_TOKEN won't trigger a separate workflow.
- name: Publish to PyPI
if: steps.changed.outputs.changed == 'true' && !inputs.dry_run
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2