-
Notifications
You must be signed in to change notification settings - Fork 2
350 lines (323 loc) · 20.1 KB
/
Copy pathcode-quality.yml
File metadata and controls
350 lines (323 loc) · 20.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
338
339
340
341
342
343
344
345
346
347
348
349
350
name: Code Quality
# SonarQube (static analysis + coverage) and Snyk (dependency vulnerabilities).
#
# NOTE (#291): the tokenless baseline now lives in `security.yml` -- Dependabot, a weekly
# `pip-audit` over the exported lock, and CodeQL, all on GITHUB_TOKEN alone, always on.
# This workflow is the OPTIONAL enhanced tier: SonarQube and Snyk run only when everything
# each scan needs is configured -- which is MORE than the tokens (see #402 below) -- and
# the `preflight` skips whichever scan is not ready, cleanly (never red), until it is.
# The two workflows disagree about nothing: baseline there, depth here.
# ┌─ BEFORE EACH SCAN CAN RUN ───────────────────────────────────────────────────────────────────
# │ The two repository secrets were created on 2026-08-18. From that moment every push to `main`
# │ failed (#402): the old preflight treated "both tokens exist" as "both scans can run", the
# │ scans started for real, and each died on a prerequisite that no repository secret carries:
# │
# │ SONAR_TOKEN -- exists. SonarQube Cloud still refuses the analysis with
# │ "You must define the following mandatory properties ...:
# │ sonar.organization" until `sonar.organization` is ACTIVE in
# │ `sonar-project.properties`. It is deliberately left commented
# │ out there until the org owner sets the real key, because a
# │ wrong key fails with a misleading "project not found".
# │ sonar.organization -- SonarCloud > organization settings; set it in
# │ `sonar-project.properties` at the repo root.
# │ SNYK_TOKEN -- exists. Snyk still rejects the scan server-side (422,
# │ SNYK-OS-PYTHON-0013) until the Snyk organization exists and is
# │ wired to this project -- org-level setup on snyk.io.
# │ SNYK_ORG -- a repository VARIABLE (Settings > Secrets and variables >
# │ Actions > Variables) holding the Snyk organization id; the
# │ scans pass it as `--org`. Declared a variable, not a secret,
# │ because an organization id is not sensitive.
# │
# │ The preflight below checks every one of these per scanner, so `main` is green now and each
# │ scan lights up THE MOMENT its own missing piece appears -- no coordinated flag day.
# │ UNTIL THEN, this workflow does NOT fail the build. It skips what is not ready and explains
# │ itself in the run summary -- unless a human DISPATCHED it, in which case it fails loudly,
# │ because silently ignoring a direct request is worse. See `preflight` for the full argument.
# └───────────────────────────────────────────────────────────────────────────────────────────────
#
# TRIGGERS. This was `workflow_dispatch` + `schedule` only, to avoid the automatic Actions spend a
# per-merge trigger implies. **That constraint is gone: the repository became PUBLIC on 2026-08-15,
# and GitHub bills no Actions minutes for standard runners on public repositories.** The cost
# argument that kept this off `push` no longer holds, so it now also runs on every merge to `main`.
#
# The scheduled run is KEPT rather than replaced, deliberately: dependency scanning is the one kind
# of check that finds something new WITHOUT the code changing. A CVE published against a version of
# `cryptography` that keel has been pinning, unmodified, for months is invisible to a per-merge
# trigger and obvious to a weekly one. Static analysis has no such property, but it is cheap to
# fold into the same pass.
#
# `pull_request` is still deliberately absent, and on a public repo that is now a SECURITY choice
# rather than a cost one: `pull_request` fires for forks, fork runs receive no repository secrets,
# so every fork PR would fail at the preflight below for a reason the contributor cannot fix.
on:
workflow_dispatch:
push:
branches: [main]
schedule:
# 06:00 UTC every Monday. A fixed weekday makes a newly-appeared finding easy to date, and
# off-the-hour minutes avoid the top-of-hour scheduling queue on GitHub's shared runners.
- cron: "0 6 * * 1"
# `github.ref` is `refs/heads/<branch>` for both a manual dispatch and a scheduled run (a schedule
# always runs on the default branch), so two overlapping runs of the same branch collapse into
# one. `cancel-in-progress` is false, unlike `ci.yml`: a Snyk `monitor` run that gets cancelled
# halfway leaves the dashboard holding a stale snapshot, which is worse than waiting.
concurrency:
group: code-quality-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
jobs:
# Both scanners fail in unhelpful ways when something they need is missing -- Sonar reports
# "You must define the following mandatory properties ...: sonar.organization", and Snyk exits
# with a generic 422 from inside the CLI. Neither says which repository or organization setting
# is the actual cause and the only thing the reader needs to know. This job says it once, up
# front, PER SCANNER.
#
# #402 is why readiness is measured per scanner and beyond token presence. When the workflow
# was written no secret existed, so "are both tokens set?" was an honest readiness check and
# one shared `configured` flag was safe. The tokens appeared on 2026-08-18, the flag flipped
# true, both scans ran for the first time -- and both failed, because each still lacked an
# ORGANIZATION-level prerequisite (SonarCloud's mandatory `sonar.organization`; the Snyk
# organization the project must be filed under). Every push to `main` went red for reasons
# the repository could not fix: exactly the permanently-red-main this preflight exists to
# prevent. The check is now: per scanner, everything that scan actually needs.
#
# ⚠️ IT SAYS IT DIFFERENTLY DEPENDING ON WHO ASKED, and that asymmetry is the whole point:
#
# workflow_dispatch -> a human asked for a scan. If it cannot run, FAIL, loudly. Silently
# doing nothing in response to a direct request is the worse outcome.
# push / schedule -> nobody asked; the trigger fired on its own. If a scan is not fully
# configured, SKIP it cleanly and say so in the run summary.
#
# `push: [main]` was added in #272 when the repo went public and Actions minutes stopped being
# billed, and a skipped scan that announces itself is still the right behaviour there: a red X
# on every merge teaches the reader to ignore CI, which costs more than the missing scan does.
#
# `secrets` cannot be referenced from a job-level `if:`, which is why this is an OUTPUT consumed
# by the two scan jobs rather than a condition written directly on them. The outputs are
# SEPARATE (`sonar_ready`, `snyk_ready`) so each scan lights up the moment its own
# prerequisites are in place -- one shared flag would keep Snyk dark over a Sonar gap, and
# vice versa.
preflight:
name: Check each scan is actually configured
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
sonar_ready: ${{ steps.check.outputs.sonar_ready }}
snyk_ready: ${{ steps.check.outputs.snyk_ready }}
steps:
# The Sonar check reads `sonar-project.properties` from the repository. Default shallow
# clone is enough; nothing from the repo is executed here.
- name: Checkout code
uses: actions/checkout@v7
- name: Verify SonarQube and Snyk are each ready to run
id: check
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
SNYK_ORG: ${{ vars.SNYK_ORG }}
run: |
set -euo pipefail
if [ ! -f sonar-project.properties ]; then
# If the grep below simply found nothing, the Sonar scan would be skipped FOREVER,
# on every event, with the workflow claiming to be "not configured yet". Fail
# loudly instead -- a missing file is a broken workflow, not a missing setting.
echo "::error::preflight cannot read sonar-project.properties -- is the checkout step gone?"
exit 1
fi
# Each scanner's list is measured against what THAT scan actually needs (#402).
SONAR_MISSING=""
SNYK_MISSING=""
[ -n "${SONAR_TOKEN:-}" ] || SONAR_MISSING="$SONAR_MISSING SONAR_TOKEN"
# SonarQube Cloud refuses the analysis without an active `sonar.organization`. It is
# commented out in sonar-project.properties until the org owner fills in the real
# key -- a wrong key fails with a misleading "project not found" -- so "the line is
# still commented" genuinely means "not ready", and the scan must not run. The regex
# must demand a non-blank VALUE, not merely an uncommented line: SonarCloud's
# "You must define ... sonar.organization" refusal fires for an EMPTY value too, so
# accepting `sonar.organization=` (uncommented, nothing after the equals sign) as
# ready would mark the scan configured and redden every push to main again (#402's
# exact failure, one edit later).
grep -Eq '^[[:space:]]*sonar\.organization[[:space:]]*=[[:space:]]*[^[:space:]]' \
sonar-project.properties \
|| SONAR_MISSING="$SONAR_MISSING sonar.organization"
[ -n "${SNYK_TOKEN:-}" ] || SNYK_MISSING="$SNYK_MISSING SNYK_TOKEN"
# The Snyk organization to file the project under, passed as `--org` below. A
# repository VARIABLE rather than a secret: an organization id is not sensitive,
# and the preflight needs to branch on it, which secrets cannot do from a job `if:`.
[ -n "${SNYK_ORG:-}" ] || SNYK_MISSING="$SNYK_MISSING SNYK_ORG"
if [ -z "$SONAR_MISSING" ]; then
echo "sonar_ready=true" >> "$GITHUB_OUTPUT"
else
echo "sonar_ready=false" >> "$GITHUB_OUTPUT"
fi
if [ -z "$SNYK_MISSING" ]; then
echo "snyk_ready=true" >> "$GITHUB_OUTPUT"
else
echo "snyk_ready=false" >> "$GITHUB_OUTPUT"
fi
if [ -z "$SONAR_MISSING" ] && [ -z "$SNYK_MISSING" ]; then
echo "SonarQube and Snyk are both fully configured."
exit 0
fi
MISSING="$SONAR_MISSING$SNYK_MISSING"
{
echo "### Code quality scans skipped"
echo
echo "Something these scans need is not configured, so the unready one(s) were"
echo "skipped. This is not a build failure."
if [ -n "$SONAR_MISSING" ]; then
echo
echo "**SonarQube scan** still needs:"
for s in $SONAR_MISSING; do
case "$s" in
SONAR_TOKEN)
echo "- \`SONAR_TOKEN\` -- repository secret. SonarQube: My Account > Security >"
echo " Generate Token; add it at [Settings > Secrets > Actions](https://github.com/${{ github.repository }}/settings/secrets/actions)" ;;
sonar.organization)
echo "- \`sonar.organization\` -- uncomment/set it in \`sonar-project.properties\`"
echo " to the SonarCloud organization key. SonarQube Cloud rejects the scan"
echo " without it, even with a valid token" ;;
esac
done
fi
if [ -n "$SNYK_MISSING" ]; then
echo
echo "**Snyk dependency scan** still needs:"
for s in $SNYK_MISSING; do
case "$s" in
SNYK_TOKEN)
echo "- \`SNYK_TOKEN\` -- repository secret. Snyk: Account Settings > Auth Token"
echo " (or \`snyk config get api\`); add it at [Settings > Secrets > Actions](https://github.com/${{ github.repository }}/settings/secrets/actions)" ;;
SNYK_ORG)
echo "- \`SNYK_ORG\` -- repository VARIABLE at [Settings > Secrets and variables > Actions](https://github.com/${{ github.repository }}/settings/variables/actions):"
echo " the Snyk organization id the project is filed under (the scans pass it"
echo " as \`--org\`). The Snyk organization itself is created on snyk.io" ;;
esac
done
fi
} >> "$GITHUB_STEP_SUMMARY"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
# A human asked for this scan. Refusing quietly would be worse than failing.
echo "::error title=Scans not configured::You dispatched this workflow, but it cannot run until these exist:$MISSING"
exit 1
fi
echo "::notice title=Code quality scans skipped::Not fully configured yet -- missing:$MISSING. This is not a build failure; see the run summary."
# ONE whole-repo scan, not the reference's per-module matrix. The reference fans out over a
# Node monorepo whose modules each have their own package.json, tsconfig and test run, and it
# builds that list with a `./.github/actions/load-services` composite action. keel has no such
# action, and its seven `packages/*` workspace members are not independent in that way: they
# share one lockfile, one interpreter, one `pytest` invocation and one coverage report. Splitting
# them into seven Sonar projects would fragment the quality gate and the coverage number across
# units nobody releases separately. `sonar.sources` in sonar-project.properties covers keel/,
# packages/ and scripts/ in a single analysis instead.
sonarqube:
name: SonarQube scan
needs: preflight
# Own readiness output, not a shared flag (#402): the token exists but SonarQube Cloud
# still refuses the scan until `sonar.organization` is set, and this job must stay dark
# until that appears -- while running the moment it does.
if: needs.preflight.outputs.sonar_ready == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 0 # Shallow clones should be disabled for better relevancy of analysis
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Set up Python
run: uv python install # version comes from .python-version; never pin it here twice
- name: Sync dependencies
run: uv sync --all-extras --dev
# Sonar treats coverage as a first-class measure and shows 0% without a report to import.
# This is the ONLY place coverage is collected: `ci.yml` and `release.yml` deliberately keep
# their plain `pytest -q`, so instrumenting the suite here cannot slow those two down.
# `--cov` with no value reads `[tool.coverage.run]` in pyproject.toml for the source roots.
- name: Test with coverage
run: uv run pytest -q --cov --cov-report=xml
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v8
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
# Self-hosted SonarQube Server also needs SONAR_HOST_URL; SonarQube Cloud does not.
# Uncomment and add the secret if the server is self-hosted:
# SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
snyk:
name: Snyk dependency scan
needs: preflight
# Own readiness output (#402): runs only when the token AND the Snyk organization
# (`SNYK_ORG`) are configured -- the organization is what the Snyk side was missing when
# every push to main failed with a server-side 422.
if: needs.preflight.outputs.snyk_ready == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Set up Python
run: uv python install
# WHY A REQUIREMENTS FILE AND NOT `uv.lock` DIRECTLY:
# Snyk does have `uv.lock` support, but as of this writing it is Early Access and gated to
# Enterprise plans (docs.snyk.io "Support for uv"); `uv.lock` is absent from the GA
# "supported languages and package managers" list, where Python means requirements.txt,
# Pipfile(.lock), pyproject.toml+poetry.lock, or setup.py. Relying on an Early Access,
# plan-gated path would make this workflow silently depend on which Snyk plan the account
# is on. Exporting the lockfile to a fully-pinned requirements.txt uses the GA path instead,
# and loses nothing: the versions come from `uv.lock` either way.
#
# --frozen use uv.lock exactly as committed; never re-resolve in CI
# --no-hashes Snyk's requirements.txt parser does not consume hashes
# --no-dev scan what ships. mypy/pytest/ruff never reach a user's machine, so
# a CVE in one is not a vulnerability in keel. Drop this flag to
# widen the scan to the dev toolchain.
# --no-emit-workspace omit the seven `-e ./packages/...` editable entries. Those are keel's
# own code, not third-party dependencies, and a local path is not
# something Snyk can look up.
- name: Export the locked dependencies as requirements.txt
run: |
set -euo pipefail
uv export --frozen --format requirements-txt --no-hashes --no-dev \
--no-emit-workspace -o requirements.txt
echo "--- requirements.txt ---"
cat requirements.txt
# `snyk/actions/setup` installs the Snyk CLI onto the runner, rather than the per-language
# `snyk/actions/python`, which executes inside `docker://snyk/snyk:python` -- a container
# whose interpreter keel does not control and cannot pin to 3.14. Snyk publishes no
# `python-3.14` or `uv-*` action wrapper (the PR adding them, snyk/actions#206, was closed
# unmerged), so the containerised route would scan a 3.14 project from an older interpreter.
# Running the CLI directly on the runner sidesteps that entirely, and the exported
# requirements.txt is fully pinned, so nothing needs resolving at scan time anyway.
- name: Install the Snyk CLI
uses: snyk/actions/setup@v1
# Fails the job on findings. Add `--severity-threshold=high` to only fail on high/critical.
# `--org` files the project under the SNYK_ORG organization -- the same variable the
# preflight gates this job on, so a scan that runs is always a scan that lands somewhere.
- name: Snyk test
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
SNYK_ORG: ${{ vars.SNYK_ORG }}
run: snyk test --org="$SNYK_ORG" --file=requirements.txt --package-manager=pip
# Reports the current dependency tree to the Snyk dashboard so newly-published CVEs against
# these exact pins raise an alert between weekly runs. `always()` so a failing `snyk test`
# above still leaves the dashboard up to date -- the finding is the reason to record it.
# Same `--org` as the test: a monitor filed under a different organization than the one
# tested would split the project's history across two dashboards.
- name: Snyk monitor
if: always()
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
SNYK_ORG: ${{ vars.SNYK_ORG }}
run: snyk monitor --org="$SNYK_ORG" --file=requirements.txt --package-manager=pip --project-name=keel
# NO DOCKER SCAN JOB, on purpose. The reference workflow builds an image and runs
# `snyk/actions/docker` against it. keel has no Dockerfile and ships no container -- it is
# distributed as wheels built by `release.yml` and installed with pip. There is no image to
# scan, so the job is absent rather than stubbed. Add one here if keel ever grows a Dockerfile.