Sonar/Snyk · push · main #307
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Code Quality | |
| run-name: "Sonar/Snyk · ${{ github.event_name }} · ${{ github.head_ref || github.ref_name }}" | |
| # 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. | |
| # ── WHY THIS SKIPS DOCUMENTATION, AND WHY `ci.yml` DELIBERATELY DOES NOT (#644) ────────────── | |
| # This workflow reads no documentation, so a docs-only change can produce no signal from it and | |
| # the run is pure cost. Seven docs-only pull requests merged on 2026-08-30/31 fired it for | |
| # nothing. | |
| # | |
| # ⚠️ DO NOT COPY THIS BLOCK INTO `ci.yml`. keel's documentation is UNDER TEST, on purpose, and | |
| # `ci.yml` is what runs those tests: | |
| # | |
| # * `tests/test_python_floor.py` reads README.md, README.ar.md and docs/desktop-install.md and | |
| # fails when they disagree with the RELEASE's `requires-python`. It exists because five | |
| # places said 3.11 while the wheels required 3.14 (#595/#607). | |
| # * `tests/test_doc_links.py` pins the documented `SHA256SUMS-*` filenames against what | |
| # `release.yml` actually emits -- the docs promised one file, the workflow shipped three | |
| # (#605/#618). | |
| # | |
| # A docs-only pull request is the ONLY kind those pins are for. Filtering docs out of `ci.yml` | |
| # would disable them on exactly the changes they were built to catch. | |
| # | |
| # SAFE HERE for two independent reasons: `main` carries no branch protection, so no check is | |
| # "required" and a skipped run cannot leave a pull request permanently unmergeable; and | |
| # `paths-ignore` applies only to `push`/`pull_request` -- `schedule` fires regardless, so the | |
| # weekly cron below re-scans everything within seven days even on a pathological miss. | |
| # | |
| # `docs/**` sweeps up the experiment drivers and ledgers under `docs/experiments/`. Correct | |
| # here, since this workflow reads none of it; NOT correct for `ci.yml`, where | |
| # `docs/experiments/trials-ledger.jsonl` is a hash-chained artifact the research tests reason | |
| # about. | |
| on: | |
| workflow_dispatch: | |
| push: | |
| branches: [main] | |
| paths-ignore: | |
| - "docs/**" | |
| - "**/*.md" | |
| - "LICENSE" | |
| 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. | |
| # `--remote-repo-url` N'EST PAS DÉCORATIF, et son absence coûte cher. | |
| # | |
| # Les plafonds du plan gratuit Snyk (200 tests Open Source par mois, PAR | |
| # ORGANISATION et partagés avec les autres dépôts) ne s'appliquent qu'aux | |
| # dépôts PRIVÉS. keel est public, donc ses scans devraient être gratuits | |
| # et illimités. | |
| # | |
| # Sauf que le CLI ne peut pas le deviner : un checkout `actions/checkout` | |
| # n'expose pas de remote exploitable, si bien que Snyk classe par défaut | |
| # TOUT scan lancé en CI comme un test PRIVÉ — y compris sur un dépôt | |
| # public. Ce drapeau est le mécanisme documenté pour le lui dire. | |
| # | |
| # Sans lui, ce job consommerait le quota privé de l'organisation à chaque | |
| # push sur `main` — ~196 par mois sur la période mesurée le 2026-09-14, | |
| # soit à lui seul le plafond entier partagé par tous les dépôts. | |
| # | |
| # (Ce job est aujourd'hui `skipped` faute de `SNYK_TOKEN`/`SNYK_ORG`. Le | |
| # drapeau est posé maintenant précisément pour que son activation ne | |
| # vienne pas vider le quota sans prévenir.) | |
| - name: Snyk test | |
| env: | |
| SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} | |
| SNYK_ORG: ${{ vars.SNYK_ORG }} | |
| REMOTE_REPO_URL: ${{ github.server_url }}/${{ github.repository }} | |
| run: snyk test --org="$SNYK_ORG" --remote-repo-url="$REMOTE_REPO_URL" --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 }} | |
| REMOTE_REPO_URL: ${{ github.server_url }}/${{ github.repository }} | |
| run: snyk monitor --org="$SNYK_ORG" --remote-repo-url="$REMOTE_REPO_URL" --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. |