Skip to content

Sonar/Snyk · push · main #307

Sonar/Snyk · push · main

Sonar/Snyk · push · main #307

Workflow file for this run

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.