-
Notifications
You must be signed in to change notification settings - Fork 1
254 lines (231 loc) · 13.8 KB
/
Copy pathcode-quality.yml
File metadata and controls
254 lines (231 loc) · 13.8 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
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 if the two
# secrets below are ever created, and its `preflight` skips them cleanly (never red) while
# they are not. The two workflows disagree about nothing: baseline there, depth here.
# ┌─ BEFORE THIS CAN RUN ─────────────────────────────────────────────────────────────────────────
# │ Two repository secrets must exist. As of 2026-08-16, NEITHER DOES -- `gh secret list` is empty
# │ at both repo and org level:
# │
# │ SONAR_TOKEN -- from SonarQube ("My Account" -> Security -> Generate Token)
# │ SNYK_TOKEN -- from Snyk (Account Settings -> Auth Token, or `snyk config get api`)
# │
# │ Set both at https://github.com/CodeGateSoftware/keel/settings/secrets/actions.
# │ `sonar-project.properties` at the repo root also carries a `sonar.projectKey` that must match
# │ the project as it exists on the Sonar server, and a commented-out `sonar.organization` that
# │ SonarQube Cloud requires -- read that file before the first run.
# │
# │ UNTIL THEY EXIST, this workflow does NOT fail the build. It skips both scans 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 their token is missing -- Sonar reports "You're not
# authorized. Please check the property sonar.token", and Snyk exits with a generic auth error
# from inside a container. Neither says "the repository secret does not exist", which is the
# actual cause and the only thing the reader needs to know. This job says it once, up front.
#
# ⚠️ 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 the tokens are not
# configured, SKIP cleanly and say so in the run summary.
#
# The second half exists because `push: [main]` was added in #272 when the repo went public and
# Actions minutes stopped being billed. Before that this workflow ran weekly, so an unconfigured
# repo produced one red X every Monday. On every merge, the same behaviour is a permanently red
# `main` that teaches the reader to ignore CI -- which costs more than the missing scan does.
# A skipped scan that announces itself is honest; a red X nobody reads is not.
#
# `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.
preflight:
name: Check required secrets
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
configured: ${{ steps.check.outputs.configured }}
steps:
- name: Verify SONAR_TOKEN and SNYK_TOKEN are set
id: check
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
run: |
set -euo pipefail
MISSING=""
[ -n "${SONAR_TOKEN:-}" ] || MISSING="$MISSING SONAR_TOKEN"
[ -n "${SNYK_TOKEN:-}" ] || MISSING="$MISSING SNYK_TOKEN"
if [ -z "$MISSING" ]; then
echo "configured=true" >> "$GITHUB_OUTPUT"
echo "SONAR_TOKEN and SNYK_TOKEN are both present."
exit 0
fi
echo "configured=false" >> "$GITHUB_OUTPUT"
{
echo "### Code quality scans skipped"
echo
echo "These repository secrets do not exist, so SonarQube and Snyk cannot run:"
echo
for s in $MISSING; do echo "- \`$s\`"; done
echo
echo "Add them at [Settings > Secrets > Actions](https://github.com/${{ github.repository }}/settings/secrets/actions):"
echo
echo "| secret | where to get it |"
echo "| --- | --- |"
echo "| \`SONAR_TOKEN\` | SonarQube: My Account > Security > Generate Token |"
echo "| \`SNYK_TOKEN\` | Snyk: Account Settings > Auth Token (or \`snyk config get api\`) |"
} >> "$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=Missing repository secrets::You dispatched this workflow, but it cannot run until these secrets exist:$MISSING"
exit 1
fi
echo "::notice title=Code quality scans skipped::Not 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 five `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 five 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
if: needs.preflight.outputs.configured == '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
if: needs.preflight.outputs.configured == '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 five `-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.
- name: Snyk test
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
run: snyk test --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.
- name: Snyk monitor
if: always()
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
# PLACEHOLDER -- SET BEFORE FIRST USE, OR DELETE:
# Add `--org=<keel-snyk-org-id>` below to file this project under a specific Snyk
# organization. It is deliberately omitted rather than copied from the workflow this was
# modelled on, whose `--org` value belongs to an unrelated project; with no `--org`, Snyk
# uses the token's default organization, which is correct for a single-org account.
run: snyk monitor --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.