From d085307c43dedb21f510581f1b704a0ae1e5ec4c Mon Sep 17 00:00:00 2001 From: paulohenriquevn Date: Thu, 27 Aug 2026 15:12:16 -0300 Subject: [PATCH 1/4] feat(sonar): a reusable SonarQube Cloud analysis workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI-based analysis, because Automatic Analysis does not import coverage — its own documentation says "code coverage information is not supported". The nine `theokit*` projects were analysing today and reporting no coverage at all, which leaves the quality gate blind to the metric the suites are actually enforcing. One workflow rather than a step copied into eight repositories, and the reason is measured. The one inline version that existed — `theokit-tui`, added by B-126 — carried `if: matrix.node-version == '22.x'` against a matrix of `['22.12', '22']`. That value never occurs, so the step reported `skipped` on every run since it was written, and a skipped step renders exactly like a passing one. Copying it eight times would have copied that. Three decisions worth stating: - `fetch-depth: 0`. On a shallow clone every line looks like it arrived in the last commit, so the scanner attributes all issues to it and the new-code period — which is what the quality gate measures — describes the wrong change. - Coverage runs with `continue-on-error`. The caller's own test job gates the suite; here a broken coverage run should cost the coverage number, not take the security findings down with it. - `sonar-project.properties` is required, not defaulted. Without it the scanner analyses build output and reports a file as a duplicate of its own bundle — measured on theokit-tui. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0186oaXQM4r2CdhRJgdTGhLq --- .github/workflows/sonar.yml | 141 ++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 15 ++++ 2 files changed, 156 insertions(+) create mode 100644 .github/workflows/sonar.yml diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml new file mode 100644 index 0000000..98ef5e4 --- /dev/null +++ b/.github/workflows/sonar.yml @@ -0,0 +1,141 @@ +# Reusable SonarQube Cloud analysis for the Theokit ecosystem. +# +# jobs: +# sonar: +# uses: usetheokit/shared-workflows/.github/workflows/sonar.yml@v1 +# secrets: inherit +# with: +# coverage-command: pnpm test:coverage +# +# ## Why this is not eight copies of a step +# +# The step existed in exactly one repository, inline, and had been dead since the day it was +# written: `if: matrix.node-version == '22.x'` against a matrix of `['22.12', '22']`, a value that +# never occurs. It reported `skipped` on every run for two months, and a skipped step renders the +# same as a passing one in the checks list. Copying that step into eight more repositories would +# have copied the class of defect with it — which is the argument this repository exists to make. +# +# ## Why CI-based analysis rather than Automatic Analysis +# +# Automatic Analysis does not import coverage: "Code coverage information is not supported" +# (SonarQube Cloud docs, Automatic Analysis). It is otherwise fine, and it is what runs today. A +# caller that adopts this workflow is choosing to trade a zero-config scan for one that knows how +# much of the code its tests actually reach — and SonarCloud disables Automatic Analysis for a +# project as soon as a CI-based analysis reports, so the two do not run twice over one commit. +name: SonarQube Cloud + +on: + workflow_call: + inputs: + node-version: + description: 'Node used for the scan. One version, not a matrix — see `concurrency` below.' + type: string + default: '22.12' + pnpm-version: + description: 'pnpm pinned through corepack. Must match the caller packageManager field.' + type: string + default: '10.34.1' + coverage-command: + description: | + Command that writes the lcov files named in the caller's `sonar-project.properties`. + Leave empty to scan without coverage — the analysis still reports bugs, vulnerabilities + and smells, it just cannot say what the tests reach. + type: string + default: '' + install-command: + description: 'Override when the repository does not install with a frozen pnpm lockfile.' + type: string + default: '' + +# One analysis per ref. Two scans of the same commit race for the same server-side report, and the +# loser overwrites the winner with an identical result — wasted minutes, and a confusing history. +concurrency: + group: sonar-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + sonar: + name: SonarQube Cloud + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + # Full history. The scanner assigns each issue to the commit that introduced it, and on a + # depth-1 clone every line looks like it arrived in the last commit — which makes the "new + # code" period, and therefore the quality gate, describe the wrong change. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Announce a missing token instead of failing on it + id: token + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + if [ -z "${SONAR_TOKEN}" ]; then + echo "::warning title=SonarQube Cloud::SONAR_TOKEN is not set — analysis skipped." + echo "present=false" >> "$GITHUB_OUTPUT" + else + echo "present=true" >> "$GITHUB_OUTPUT" + fi + + - name: Enable Corepack and pin pnpm + if: steps.token.outputs.present == 'true' && hashFiles('pnpm-lock.yaml') != '' + env: + PNPM_VERSION: ${{ inputs.pnpm-version }} + run: | + corepack enable + corepack prepare "pnpm@${PNPM_VERSION}" --activate + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + if: steps.token.outputs.present == 'true' + with: + node-version: ${{ inputs.node-version }} + + - name: Install + if: steps.token.outputs.present == 'true' && inputs.coverage-command != '' + env: + OVERRIDE: ${{ inputs.install-command }} + run: | + set -euo pipefail + if [ -n "${OVERRIDE}" ]; then + eval "${OVERRIDE}" + elif [ -f pnpm-lock.yaml ]; then + pnpm install --frozen-lockfile + else + npm ci + fi + + # Coverage failing must not fail the analysis. The caller's own test job is what gates the + # suite; here a broken coverage run should cost the coverage NUMBER, not the whole scan — + # otherwise one flaky test takes the security findings down with it. + - name: Coverage + if: steps.token.outputs.present == 'true' && inputs.coverage-command != '' + continue-on-error: true + env: + COVERAGE_COMMAND: ${{ inputs.coverage-command }} + run: | + echo "::group::coverage" + eval "${COVERAGE_COMMAND}" || echo "::warning title=Coverage::command failed — scanning without it" + echo "::endgroup::" + + # The scanner reads `sonar-project.properties` from the repository root. A caller without + # that file gets a scan of everything, including build output, so its absence is an error + # here rather than a default. + - name: Require sonar-project.properties + if: steps.token.outputs.present == 'true' + run: | + if [ ! -f sonar-project.properties ]; then + echo "::error title=SonarQube Cloud::sonar-project.properties is missing from the repository root." + echo "Without it the scanner analyses build output and reports a file as a duplicate of its own bundle." + exit 1 + fi + + - name: Analyse + if: steps.token.outputs.present == 'true' + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: npx --yes sonarqube-scanner@4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 61817d9..62cdab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- A reusable SonarQube Cloud analysis workflow (`.github/workflows/sonar.yml`). Callers get a scan + that imports coverage, which Automatic Analysis cannot do — its documentation states that "code + coverage information is not supported". It requires `sonar-project.properties` at the repository + root and fails loudly without it, because a scanner with no declared scope reads build output and + reports a source file as a duplicate of its own bundle. A missing `SONAR_TOKEN` warns and skips + rather than failing, so adopting the workflow does not turn a repository red before the secret + exists. + + It exists as one workflow rather than eight copied steps for a measured reason: the only inline + version in the ecosystem, in `theokit-tui`, carried `if: matrix.node-version == '22.x'` against a + matrix of `['22.12', '22']`. The condition was never true, the step reported `skipped` on every + run for two months, and a skipped step is indistinguishable from a passing one in the checks list. + ## [dep-check 0.9.1] - 2026-08-27 ### Fixed From ccc02f41f7c21cbc0771e1d2c0c639eb5eb10f2d Mon Sep 17 00:00:00 2001 From: paulohenriquevn Date: Thu, 27 Aug 2026 15:19:11 -0300 Subject: [PATCH 2/4] docs(sonar): the scanner refuses to run beside Automatic Analysis, it does not replace it The header claimed SonarCloud "disables Automatic Analysis for a project as soon as a CI-based analysis reports". It does not. The scanner fails: [ERROR] ScannerEngine: You are running CI analysis while Automatic Analysis is enabled. Please consider disabling one or the other. Measured on theokit-tui: with the step finally executing (the `22.x` condition fixed) and the token present, the run failed on exactly this and nothing else. Turning the toggle off by hand under Administration > Analysis Method is a prerequisite, not something the workflow handles. Written down here because a caller reading the old sentence would adopt the workflow and get a red check with no idea it needed a manual step. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0186oaXQM4r2CdhRJgdTGhLq --- .github/workflows/sonar.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 98ef5e4..7934e1f 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -20,8 +20,18 @@ # Automatic Analysis does not import coverage: "Code coverage information is not supported" # (SonarQube Cloud docs, Automatic Analysis). It is otherwise fine, and it is what runs today. A # caller that adopts this workflow is choosing to trade a zero-config scan for one that knows how -# much of the code its tests actually reach — and SonarCloud disables Automatic Analysis for a -# project as soon as a CI-based analysis reports, so the two do not run twice over one commit. +# much of the code its tests actually reach. +# +# ADOPTING THIS REQUIRES TURNING AUTOMATIC ANALYSIS OFF FIRST, per project, under +# Administration > Analysis Method. The scanner refuses to run alongside it rather than taking +# over: +# +# [ERROR] ScannerEngine: You are running CI analysis while Automatic Analysis is enabled. +# Please consider disabling one or the other. +# +# An earlier version of this comment claimed SonarCloud disables Automatic Analysis on its own as +# soon as a CI analysis reports. It does not — measured on theokit-tui, where the step failed with +# exactly the error above until the toggle was switched off by hand. name: SonarQube Cloud on: From 6fa0c6ebfde9e46315139cbad1eedabfa6b4f2dc Mon Sep 17 00:00:00 2001 From: paulohenriquevn Date: Thu, 27 Aug 2026 15:46:36 -0300 Subject: [PATCH 3/4] fix(sonar): declare SONAR_TOKEN instead of asking callers to inherit everything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `secrets: inherit` hands the called workflow every secret the caller holds. zizmor flags it (`secrets-inherit`), and it is right to: this workflow needs one token to publish an analysis, and inheriting gives it npm credentials, app private keys and anything else the repository carries. Declared as `required: false`, so a repository without the secret still runs the job — the existing step warns and skips rather than failing, which is what lets a repository adopt the workflow before the secret exists. Caught by running zizmor locally against the four callers before pushing them, rather than by watching four CI runs go red. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0186oaXQM4r2CdhRJgdTGhLq --- .github/workflows/sonar.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 7934e1f..6995c05 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -2,8 +2,9 @@ # # jobs: # sonar: -# uses: usetheokit/shared-workflows/.github/workflows/sonar.yml@v1 -# secrets: inherit +# uses: usetheokit/shared-workflows/.github/workflows/sonar.yml@v1 # zizmor: ignore[unpinned-uses] +# secrets: +# SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} # with: # coverage-command: pnpm test:coverage # @@ -56,6 +57,16 @@ on: description: 'Override when the repository does not install with a frozen pnpm lockfile.' type: string default: '' + secrets: + SONAR_TOKEN: + description: | + SonarCloud analysis token. Declared rather than inherited: `secrets: inherit` hands this + workflow EVERY secret the caller holds, and zizmor flags it for that reason. Naming the + one secret it needs is the difference between a scanner that can publish an analysis and + a scanner that could publish anything. + + Optional, so a repository without the secret still runs the job — it warns and skips. + required: false # One analysis per ref. Two scans of the same commit race for the same server-side report, and the # loser overwrites the winner with an identical result — wasted minutes, and a confusing history. From 9d33fb40c7e59cc2271268eee1465f07b7948656 Mon Sep 17 00:00:00 2001 From: paulohenriquevn Date: Thu, 27 Aug 2026 16:24:36 -0300 Subject: [PATCH 4/4] fix(sonar): the scan needs a build step, because a workspace tests through dist/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit theokit-sdk's first analysis passed both checks and reported no coverage at all. The scanner said only: No coverage information will be saved because all LCOV files cannot be found. The coverage step above it had already failed, and the log carried the reason: Failed to resolve entry for package "@theokit/sdk" sdk-cache: missing dist/index.js — run 'pnpm build' Its packages import each other through their published entry points, so nine suites cannot even load before `pnpm build` has run. This workflow installed and measured, never built — and `continue-on-error` on the coverage step turned that into a green check with an empty number, which is the failure mode this whole workflow exists to remove. `build-command` is a separate step rather than a prefix inside `coverage-command`: the two fail for different reasons, and a build failure that reads as a coverage failure is how the last one hid for a day. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0186oaXQM4r2CdhRJgdTGhLq --- .github/workflows/sonar.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 6995c05..6169fa6 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -57,6 +57,18 @@ on: description: 'Override when the repository does not install with a frozen pnpm lockfile.' type: string default: '' + build-command: + description: | + Run before the coverage command. A workspace whose packages import each other through + their published entry points needs `dist/` to exist before any test can import them — + measured on theokit-sdk, where the coverage run failed with + `Failed to resolve entry for package "@theokit/sdk"` and three assertions reading + `missing dist/index.js — run 'pnpm build'`. The scan still completed, silently, with no + coverage at all. + + Leave empty for repositories that test from source. + type: string + default: '' secrets: SONAR_TOKEN: description: | @@ -130,9 +142,24 @@ jobs: npm ci fi + # Built before coverage, when the caller asks for it. Not folded into `coverage-command` + # because the two fail for different reasons and deserve separate lines in the log. + - name: Build + if: steps.token.outputs.present == 'true' && inputs.build-command != '' + env: + BUILD_COMMAND: ${{ inputs.build-command }} + run: | + echo "::group::build" + eval "${BUILD_COMMAND}" + echo "::endgroup::" + # Coverage failing must not fail the analysis. The caller's own test job is what gates the # suite; here a broken coverage run should cost the coverage NUMBER, not the whole scan — # otherwise one flaky test takes the security findings down with it. + # + # It is a WARNING and not a silence: theokit-sdk's first run passed both checks while + # reporting no coverage at all, because the command failed for a reason (`missing + # dist/index.js`) that only the log carried. - name: Coverage if: steps.token.outputs.present == 'true' && inputs.coverage-command != '' continue-on-error: true