Skip to content

CI gate

tingle check is stat --diff with an opinion. It measures the same branch impact, then exits 1 if the branch made things worse — so a pull request that takes on debt fails the build.

Output is trimmed to what CI needs: the metrics that grew, and only the lines the branch added.

$ tingle check
noqa-comment (regex_count): +2
  + src/api/views.py:23
  + src/api/views.py:41

ruff-ignores (toml_list_length): +1
  + pyproject.toml: D203

$ echo $?
1

A branch that worsens nothing prints nothing and exits 0.

Policies

Two policies decide what "worse" means:

Policy Fails when
sum (default) the metrics grow in total — paying off debt in one metric offsets taking it on in another
any any single metric grows, whatever else improved
[check]
policy = "sum"        # or "any"
ignore = ["loc"]      # metrics that never fail the check

Only the config decides what the gate judges and how: check takes no option that changes the policy or narrows the metrics, so loosening the gate is a config change a review can see.

Under sum, a metric can grow and the branch still pass. Its added lines are still printed, because the trade is worth seeing even when it is allowed.

Ignoring metrics

ignore names metrics that are expected to grow — lines of code, say. They take no part in the check: they neither move the total nor fail the build, and they stay out of the output.

A name that matches no configured metric is a config error, so a typo cannot silently ignore nothing.

Strict mode

A false positive is fixed in the config, and that fix can ride along on the branch it turns green, where a review sees a feature rather than a looser gate. strict puts loosening on a branch of its own:

[check]
strict = true

With it on, a branch fails when it loosens the config and changes anything else. The same config change alone passes.

$ tingle check
config loosened alongside other changes:
  noqa-comment: 12 to 11
  ~ src/api/views.py

check failed: noqa-comment loosened alongside 1 other changed file

The branch's tree is measured under the base's config and under its own, so only the config can move the numbers. Loosening is any of these:

Change Reported as
a judged metric scores lower noqa-comment: 12 to 11
a judged metric is removed, or renamed noqa-comment: removed
a judged metric moves to [check] ignore noqa-comment: moved to [check] ignore
policy goes from any to sum [check] policy: any to sum
strict is turned off [check] strict: turned off
a template module in the project changes pack/rules.py: template module changed

A config change that only raises a score may ride along. Lowered metrics are judged one by one, so raising one never pays for lowering another.

Everything the branch changed counts as "anything else" except the config file and those template modules. With config in pyproject.toml, a change outside [tool.tingle] counts too.

What decides is the base's config: strict is read from the merge-base, so a branch cannot drop it to get past it, and --config cannot point around it. The branch that turns it on is not held to it.

With no base config to compare with, because there is none, it cannot be parsed or it no longer validates, strict mode cannot judge the branch. check says so and carries on. The exception is a base that asked for strict and stopped validating while the branch changed or deleted a template module it names: that counts as loosening. The module is recognised by its dotted path, so a package whose directory does not match its name is not seen.

Only the config is compared. A newer tingle whose bundled patterns differ is not seen, because only one version is installed. Under advisory CI a strict failure is a red job like any other, and does not block the merge.

Gating on spread rather than volume

ignore is the blunt instrument. Where a metric is not so much expected to grow as expected to churn, the sharper answer is to gate on how far the thing has spread instead of how often it is written: regex_spread and symbol_spread count the files a thing appears in, so a branch that reworks legacy code passes while a branch that carries it into a new file does not.

It is the difference between "stop using this" and "stop spreading this", and only the second can be gated on without failing every bug fix.

Exit codes

Code Meaning
0 the branch worsened nothing
1 the branch is a regression under the active policy (or a metric function failed)
2 config or usage error

GitHub Actions

A complete workflow that fails a pull request which takes on debt:

name: Metrics

on: [pull_request]

jobs:
  tingle:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0        # check needs history for the merge-base
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - run: pip install tingle
      - run: tingle check

fetch-depth: 0 is not optional

actions/checkout clones shallow by default, and check needs history to find the merge-base with the base branch. Without it the job fails on every run.

Advisory: report without blocking the merge

tingle has false positives, so a red check is not always a real problem. To show the branch's debt on every pull request without blocking the merge, put check in its own job with continue-on-error: true. The job goes red when the branch worsens the metrics, so the failure shows in the checks list, but the workflow still passes. tingle's own CI runs it this way.

  tingle:
    runs-on: ubuntu-latest
    continue-on-error: true     # report, but never block the merge
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - run: pip install tingle
      - run: tingle check

Branch protection overrides this

continue-on-error keeps the workflow from failing, but it does not make a check non-required. If the job is listed as a required status check in a branch protection rule, it will still block the merge. Leave it out of that list.

Either way, fix each false positive in the config with a narrower pattern, an ignore_lines entry or a range exclude, and name in [check] ignore the metrics that are expected to grow.

Feedback on the pull request

A failing job says the branch took on debt; the reviewer still has to open the log to see where. The pr-feedback action puts it where review happens. It runs check, then:

  • annotates each line the branch added debt on, under the metrics that grew: an error when the check fails, a warning when the policy let it pass;
  • comments once on the pull request with the per-metric delta and every addition, and edits that comment on each push rather than adding another.

It fails the step exactly when check does, so it replaces the check step:

  tingle:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write    # the comment
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - run: pip install tingle
      - uses: fancysnake/tingle/actions/pr-feedback@main
Input Default Meaning
config auto-discovered path to tingle.toml; annotations follow it into a subdirectory
comment true false keeps the annotations and posts no comment
key the config input, else default names the comment a run owns, together with the user or App behind the token; give each config its own when one pull request runs several
github-token github.token token used to read and write the comments; a run edits only a comment the same user or App wrote, so a renewed token keeps editing the old comment, and a token for a different user or App starts a new comment and leaves the old one as it was

What GitHub will not show

GitHub shows at most ten annotations of each level per step, so a branch adding more is annotated in part; the comment lists every addition. A pull request from a fork gets a read-only token: the annotations still land, the comment is skipped with a warning.

Annotations sit on lines; an addition with no line — a list entry such as a new ruff ignore, or a whole new file — annotates the file instead.

Publishing the numbers

check only judges. To also record the values, add stat --json — it is a separate command and does not affect the exit code:

      - run: tingle stat --json > metrics.json
      - run: jq -r '.metrics[] | "\(.name)\t\(.value)"' metrics.json
      - uses: actions/upload-artifact@v4
        with:
          name: metrics
          path: metrics.json

An artifact is a file, not a history — it expires and nothing plots it. To keep the values and chart them over time, see History.

GitLab

Set GIT_DEPTH: 0 so the merge-base is reachable. tingle report --cobertura emits Cobertura XML in which each occurrence line is an uncovered line, which the MR widget consumes directly.

tingle:
  variables:
    GIT_DEPTH: 0
  script:
    - tingle check
    - tingle report --cobertura > tingle.xml
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: tingle.xml

Git hooks

CI judges the branch; a hook only reports, before the commit leaves your machine. tingle report --diff --base HEAD measures your uncommitted changes against the last commit and lists what they add and remove; it fails only on a config bug: a metric that errors, or a config or diff it cannot load. A hook that failed on new debt would leave --no-verify as the way past a false positive, and that is a suppression; the fix belongs in the config, and the gate in CI.

The hook measures the working tree. pre-commit stashes unstaged edits but not untracked files, so a stray untracked file with a # noqa in it shows up in the report even though the commit leaves it out.

pre-commit

repos:
  - repo: https://github.com/fancysnake/tingle
    rev: v0.10.0
    hooks:
      - id: tingle

pre-commit installs tingle from the tag rev names, so the hook needs no pip install. The hook is verbose, so pre-commit prints its report even though it passes.

To report on the whole branch at push instead, run the hook at the push stage and drop its --base HEAD, so it falls back to [diff] base, then main:

      - id: tingle
        stages: [pre-push]
        args: []

The push hook measures your checkout, not the commits you push: the working tree and its untracked files against the merge-base, with nothing stashed. Push from a clean tree and the two agree.

hk

tingle publishes no hk package; the step is one line in your hk.pkl, and it runs the tingle on your PATH:

amends "package://github.com/jdx/hk/releases/download/v2.8.1/hk@2.8.1#/Config.pkl"

hooks {
  ["pre-commit"] {
    steps {
      ["tingle"] { check = "tingle report --diff --base HEAD" }
    }
  }
}