-
Notifications
You must be signed in to change notification settings - Fork 1
287 lines (260 loc) · 13.6 KB
/
Copy pathspec-sync.yml
File metadata and controls
287 lines (260 loc) · 13.6 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
name: Weekly Spec Sync
# Security model (see issue #92, audit finding F-O-01):
#
# This workflow fetches OpenAPI/AsyncAPI specs from `docs.kalshi.com`, an
# external source we do not control. A compromise of upstream (MITM,
# malicious commit to Kalshi's published spec, etc.) must NOT translate
# into writable code in this repo. Therefore:
#
# - `permissions:` grants only `contents: read` + `issues: write`.
# We never push a branch, never open a PR, never touch repo contents.
# - Detected drift opens a NEW `spec-drift`-labeled issue, one per
# distinct drift fingerprint (sha256 of openapi_sha + asyncapi_sha).
# Re-running against the same upstream is a no-op — the existing
# open issue dedups. A maintainer locally runs `scripts/sync_spec.py`
# + `scripts/generate.py`, audits `kalshi/_generated/models.py`, and
# opens a PR with `Closes #N` in the body, which auto-closes the
# drift issue on merge.
# - Third-party actions are pinned to full commit SHAs so a compromised
# mutable tag cannot escalate this workflow's narrow read scope.
on:
schedule:
# Mondays at 06:00 UTC
- cron: "0 6 * * 1"
workflow_dispatch:
permissions:
contents: read
issues: write
concurrency:
group: spec-sync
cancel-in-progress: false
jobs:
spec-sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Set up Python
run: uv python install 3.12
- name: Install dependencies
run: uv sync --dev
- name: Snapshot current specs
id: snapshot
run: |
set -euo pipefail
snapshot_one() {
# $1 = spec filename, $2 = GITHUB_OUTPUT key
if [ -f "specs/$1" ]; then
cp "specs/$1" "/tmp/$1.old"
echo "$2=true" >> "$GITHUB_OUTPUT"
else
echo "$2=false" >> "$GITHUB_OUTPUT"
fi
}
snapshot_one openapi.yaml had_old_openapi
snapshot_one asyncapi.yaml had_old_asyncapi
snapshot_one perps_openapi.yaml had_old_perps_openapi
snapshot_one perps_asyncapi.yaml had_old_perps_asyncapi
snapshot_one perps_scm_openapi.yaml had_old_perps_scm_openapi
- name: Download latest specs
run: uv run python scripts/sync_spec.py
- name: Detect spec changes
id: diff
run: |
set -euo pipefail
# changed_one: echoes "false" when a prior snapshot exists and is
# byte-identical to the freshly downloaded spec; "true" otherwise
# (changed, or first-ever fetch).
changed_one() {
# $1 = spec filename, $2 = had_old flag
if [ "$2" = "true" ] && cmp -s "/tmp/$1.old" "specs/$1"; then
echo false
else
echo true
fi
}
openapi_changed=$(changed_one openapi.yaml "${{ steps.snapshot.outputs.had_old_openapi }}")
asyncapi_changed=$(changed_one asyncapi.yaml "${{ steps.snapshot.outputs.had_old_asyncapi }}")
perps_openapi_changed=$(changed_one perps_openapi.yaml "${{ steps.snapshot.outputs.had_old_perps_openapi }}")
perps_asyncapi_changed=$(changed_one perps_asyncapi.yaml "${{ steps.snapshot.outputs.had_old_perps_asyncapi }}")
perps_scm_changed=$(changed_one perps_scm_openapi.yaml "${{ steps.snapshot.outputs.had_old_perps_scm_openapi }}")
if $openapi_changed || $asyncapi_changed || $perps_openapi_changed \
|| $perps_asyncapi_changed || $perps_scm_changed; then
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "openapi_changed=${openapi_changed}" >> "$GITHUB_OUTPUT"
echo "asyncapi_changed=${asyncapi_changed}" >> "$GITHUB_OUTPUT"
echo "perps_openapi_changed=${perps_openapi_changed}" >> "$GITHUB_OUTPUT"
echo "perps_asyncapi_changed=${perps_asyncapi_changed}" >> "$GITHUB_OUTPUT"
echo "perps_scm_changed=${perps_scm_changed}" >> "$GITHUB_OUTPUT"
echo "Spec changes detected — openapi=${openapi_changed}, asyncapi=${asyncapi_changed}, perps_openapi=${perps_openapi_changed}, perps_asyncapi=${perps_asyncapi_changed}, perps_scm=${perps_scm_changed}"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "All specs unchanged — nothing to do."
fi
- name: Extract spec metadata for report
if: steps.diff.outputs.changed == 'true'
id: meta
run: |
set -euo pipefail
old_openapi=$(uv run python scripts/extract_spec_meta.py /tmp/openapi.yaml.old)
new_openapi=$(uv run python scripts/extract_spec_meta.py specs/openapi.yaml)
old_asyncapi=$(uv run python scripts/extract_spec_meta.py /tmp/asyncapi.yaml.old)
new_asyncapi=$(uv run python scripts/extract_spec_meta.py specs/asyncapi.yaml)
old_version=$(echo "$old_openapi" | jq -r '.version')
new_version=$(echo "$new_openapi" | jq -r '.version')
old_path_count=$(echo "$old_openapi" | jq -r '.paths | length')
new_path_count=$(echo "$new_openapi" | jq -r '.paths | length')
old_channel_count=$(echo "$old_asyncapi" | jq -r '.channels | length')
new_channel_count=$(echo "$new_asyncapi" | jq -r '.channels | length')
added_paths=$(comm -13 \
<(echo "$old_openapi" | jq -r '.paths[]' | sort -u) \
<(echo "$new_openapi" | jq -r '.paths[]' | sort -u) \
|| true)
removed_paths=$(comm -23 \
<(echo "$old_openapi" | jq -r '.paths[]' | sort -u) \
<(echo "$new_openapi" | jq -r '.paths[]' | sort -u) \
|| true)
added_channels=$(comm -13 \
<(echo "$old_asyncapi" | jq -r '.channels[]' | sort -u) \
<(echo "$new_asyncapi" | jq -r '.channels[]' | sort -u) \
|| true)
removed_channels=$(comm -23 \
<(echo "$old_asyncapi" | jq -r '.channels[]' | sort -u) \
<(echo "$new_asyncapi" | jq -r '.channels[]' | sort -u) \
|| true)
{
echo "old_version=${old_version}"
echo "new_version=${new_version}"
echo "old_path_count=${old_path_count}"
echo "new_path_count=${new_path_count}"
echo "old_channel_count=${old_channel_count}"
echo "new_channel_count=${new_channel_count}"
} >> "$GITHUB_OUTPUT"
emit_list() {
local name="$1" value="$2"
echo "${name}<<EOF" >> "$GITHUB_OUTPUT"
if [ -n "$value" ]; then echo "$value"; else echo "(none)"; fi >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
}
emit_list added_paths "$added_paths"
emit_list removed_paths "$removed_paths"
emit_list added_channels "$added_channels"
emit_list removed_channels "$removed_channels"
- name: Compute spec checksums
if: steps.diff.outputs.changed == 'true'
id: checksums
run: |
set -euo pipefail
openapi_sha=$(sha256sum specs/openapi.yaml | awk '{print $1}')
asyncapi_sha=$(sha256sum specs/asyncapi.yaml | awk '{print $1}')
perps_openapi_sha=$(sha256sum specs/perps_openapi.yaml | awk '{print $1}')
perps_asyncapi_sha=$(sha256sum specs/perps_asyncapi.yaml | awk '{print $1}')
perps_scm_sha=$(sha256sum specs/perps_scm_openapi.yaml | awk '{print $1}')
echo "openapi_sha=${openapi_sha}" >> "$GITHUB_OUTPUT"
echo "asyncapi_sha=${asyncapi_sha}" >> "$GITHUB_OUTPUT"
echo "perps_openapi_sha=${perps_openapi_sha}" >> "$GITHUB_OUTPUT"
echo "perps_asyncapi_sha=${perps_asyncapi_sha}" >> "$GITHUB_OUTPUT"
echo "perps_scm_sha=${perps_scm_sha}" >> "$GITHUB_OUTPUT"
- name: Ensure spec-drift label exists
if: steps.diff.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Idempotent: --force updates if present, creates if not. Removes a
# silent-fail footgun where the create step would abort on a
# missing label and weekly cron drift would go unreported.
gh label create spec-drift \
--color 'e4e669' \
--description 'Upstream OpenAPI/AsyncAPI spec changed since last sync' \
--force \
--repo "${GITHUB_REPOSITORY}"
- name: Open or dedup spec-drift issue
if: steps.diff.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENAPI_CHANGED: ${{ steps.diff.outputs.openapi_changed }}
ASYNCAPI_CHANGED: ${{ steps.diff.outputs.asyncapi_changed }}
OLD_VERSION: ${{ steps.meta.outputs.old_version }}
NEW_VERSION: ${{ steps.meta.outputs.new_version }}
OLD_PATH_COUNT: ${{ steps.meta.outputs.old_path_count }}
NEW_PATH_COUNT: ${{ steps.meta.outputs.new_path_count }}
OLD_CHANNEL_COUNT: ${{ steps.meta.outputs.old_channel_count }}
NEW_CHANNEL_COUNT: ${{ steps.meta.outputs.new_channel_count }}
ADDED_PATHS: ${{ steps.meta.outputs.added_paths }}
REMOVED_PATHS: ${{ steps.meta.outputs.removed_paths }}
ADDED_CHANNELS: ${{ steps.meta.outputs.added_channels }}
REMOVED_CHANNELS: ${{ steps.meta.outputs.removed_channels }}
OPENAPI_SHA: ${{ steps.checksums.outputs.openapi_sha }}
ASYNCAPI_SHA: ${{ steps.checksums.outputs.asyncapi_sha }}
PERPS_OPENAPI_CHANGED: ${{ steps.diff.outputs.perps_openapi_changed }}
PERPS_ASYNCAPI_CHANGED: ${{ steps.diff.outputs.perps_asyncapi_changed }}
PERPS_SCM_CHANGED: ${{ steps.diff.outputs.perps_scm_changed }}
PERPS_OPENAPI_SHA: ${{ steps.checksums.outputs.perps_openapi_sha }}
PERPS_ASYNCAPI_SHA: ${{ steps.checksums.outputs.perps_asyncapi_sha }}
PERPS_SCM_SHA: ${{ steps.checksums.outputs.perps_scm_sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
# Drift fingerprint: sha256 over every spec's sha. Stable across re-runs
# against the same upstream; changes the moment ANY spec does (so a
# perps-only change still opens a fresh drift issue).
FINGERPRINT=$(printf '%s%s%s%s%s' \
"${OPENAPI_SHA}" "${ASYNCAPI_SHA}" "${PERPS_OPENAPI_SHA}" \
"${PERPS_ASYNCAPI_SHA}" "${PERPS_SCM_SHA}" | sha256sum | awk '{print $1}')
export FINGERPRINT
# Dedup: skip if an open weekly (spec-sync-bot) drift issue already
# embeds this fingerprint. Scoped to spec-sync-bot so the nightly
# tracker (spec-drift-bot) is never mistaken for a weekly snapshot.
existing=$(gh issue list \
--repo "${GITHUB_REPOSITORY}" \
--state open \
--label spec-drift \
--limit 200 \
--json number,body \
--jq '[.[] | select(.body | contains("spec-sync-bot")) | select(.body | contains("fingerprint:'"${FINGERPRINT}"'"))] | first | .number // empty')
if [ -n "${existing}" ]; then
echo "Drift fingerprint ${FINGERPRINT} already tracked in issue #${existing}; nothing to do."
exit 0
fi
# Render body via Python (string.Template.substitute — no shell expansion of
# upstream-sourced values). Pipe through --body-file to avoid argv size limits.
body_file=$(mktemp)
trap 'rm -f "${body_file}"' EXIT
python3 scripts/render_drift_body.py > "${body_file}"
today=$(date -u '+%Y-%m-%d')
short_fp=${FINGERPRINT:0:8}
# Lead the title with a CONTENT signal, not just info.version: Kalshi
# changes spec content in place without bumping the version, so a bare
# "3.23.0 → 3.23.0" reads like a no-op false alarm. Tag content-only
# changes and always surface the path delta + short fingerprint.
if [ "${OLD_VERSION}" = "${NEW_VERSION}" ]; then
title="Spec drift ${today}: openapi ${NEW_VERSION} [content-changed] (paths ${OLD_PATH_COUNT}→${NEW_PATH_COUNT}, fp ${short_fp})"
else
title="Spec drift ${today}: openapi ${OLD_VERSION} → ${NEW_VERSION} (paths ${OLD_PATH_COUNT}→${NEW_PATH_COUNT}, fp ${short_fp})"
fi
new_url=$(gh issue create \
--repo "${GITHUB_REPOSITORY}" \
--label spec-drift \
--title "${title}" \
--body-file "${body_file}")
echo "Opened ${new_url}"
# Supersede older weekly drift snapshots whose fingerprint no longer
# matches live upstream — keep exactly one open weekly issue instead of
# piling up one per upstream micro-revision. Scoped to spec-sync-bot so
# the nightly tracker is never touched here; the freshly-created issue
# carries the current fingerprint and is therefore excluded.
stale=$(gh issue list \
--repo "${GITHUB_REPOSITORY}" \
--state open \
--label spec-drift \
--limit 200 \
--json number,body \
--jq '[.[] | select(.body | contains("spec-sync-bot")) | select((.body | contains("fingerprint:'"${FINGERPRINT}"'")) | not) | .number] | .[]')
for n in ${stale}; do
gh issue close "${n}" \
--repo "${GITHUB_REPOSITORY}" \
--reason "not planned" \
--comment "Superseded by ${new_url} — upstream specs moved again, so this drift snapshot is stale. Reconcile against current upstream."
echo "Superseded #${n}"
done