Skip to content

Suppressions and baselines

This page covers what a team can configure about which findings count: ignore rules in .upgradescope.yaml, the upgradescope.basit.engineer/ignore object annotations, baselines for CI, the server's gate, and the agent's ClusterReadiness spec.ignore. The file format, field by field, is in the configuration file reference; the flags in upgradescope scan. The GitHub Action passes them as its config, baseline and write-baseline inputs (action/README.md).

Contents:

  1. Ignore rules (.upgradescope.yaml)
  2. Object annotations
  3. Baselines
  4. How the gate decides
  5. The server gate
  6. The agent: spec.ignore
  7. Finding keys
  8. Code scanning and accepted findings
  9. Who can turn the gate off

Ignore rules (.upgradescope.yaml)

An ignore rule accepts a finding that the team knows about and has decided to live with for now, such as an EOL ingress-nginx that is being migrated, or a removed API in manifests that are never deployed. A suppressed finding:

  • does not count toward the score, the verdict or the --fail-on gate;
  • is still reported, with its reason, in the table (a SUPPRESSED section and an N suppressed line), in JSON (a suppressed array), in the Markdown step summary and in JUnit (skipped test cases). It is not a SARIF result: see Code scanning and accepted findings.
# .upgradescope.yaml
ignore:
  # A whole finding, by key, until a date.
  - key: eol-addon/ingress-nginx
    reason: migrating to Gateway API, tracked in PLAT-123
    expires: 2099-12-31   # a date inside your migration window

  # Every removed-API finding, but only for objects under legacy/.
  - category: removed-api
    file: legacy/**
    reason: legacy/ is kept for reference and never deployed

  # One object.
  - key: removed-api/networking.k8s.io/v1beta1/Ingress
    namespace: shop
    name: admin
    reason: admin ingress is deleted in the next release

Every field, the categories a rule can name, and what makes a file invalid are in the configuration file reference.

Which findings a rule takes. A rule without namespace, name or file takes the whole finding, including objects beyond the 100 that a finding lists. With selectors it takes only the listed objects that match all of them. If every object is taken, and none were left unlisted, the finding leaves the scored list. Otherwise it stays, with the remaining objects and a note saying how many were suppressed. A finding that lists no objects (add-ons, version skew) matches a namespace selector only when every namespace it names matches, and never matches name or file. Such a finding names at most 100 namespaces and counts the rest, which cannot be shown to match, so past 100 a namespace rule no longer takes it, even with a glob such as * that would match them all; use a rule without selectors (by key) for it. An add-on install in no named namespace cannot be shown to match either: a manifest object without metadata.namespace (helm template output usually has none, and the install can land in any namespace, production included), or a cluster-scoped IngressClass. A finding that covers one is marked unnamespaced in the JSON report, and a namespace rule, a glob such as * included, never takes it: an ingress-nginx end-of-life blocker for an install in sandbox and another with no namespace stays a blocker under a rule for sandbox. A finding whose installs are all in named namespaces the rule matches is suppressed as before. Rules apply in order, and the first one that matches an object takes it.

Deprecated-API callers. When the apiserver's apiserver_requested_deprecated_apis metric shows requests to the API of an object finding, the request is folded into that finding (its callers in JSON). It is evidence about some client, not about the listed objects, so it fails closed: when annotations or rules with namespace, name or file take every object of the finding, each caller comes back as its own deprecated-api-in-use finding, at its own severity, and still counts. The client still calling a removed API may not be the one that wrote the objects. Only a rule without selectors that takes the object finding, or a rule without selectors for the caller's own deprecated-api-in-use key or category, also suppresses the caller: a caller lists no objects or namespaces, so a rule for its key with namespace, name or file matches nothing.

Expiry. On the day after expires, the rule stops applying. The finding counts again, and the scan prints a warning naming the rule, so an acceptance cannot quietly outlive its reason.

Errors and discovery. An invalid file makes the scan exit 1 before it scans anything, so a typo fails loudly instead of suppressing nothing. --config <path> names the file; otherwise .upgradescope.yaml is looked up in the scan root, then at the repository root (details).

Object annotations

An object can opt out of findings itself:

metadata:
  annotations:
    upgradescope.basit.engineer/ignore: removed-api, deprecated-api   # categories or keys, comma-separated
    upgradescope.basit.engineer/ignore-reason: replaced by the HTTPRoute in routes.yaml

The annotation works in --files mode (rendered manifests) and on live objects, which the collector reads from object metadata. It applies to findings that list objects, which today are the API-usage findings. The reason is required: an ignore annotation without ignore-reason is not applied, and the scan prints a warning. Annotations have no expiry. Annotations only work on the objects that a finding lists, at most 100 per API. On a live cluster the collector keeps at most 16 KiB of each annotation (of ignore, the categories and keys that fit whole), as the server takes it.

Where annotations apply

Every view that judges objects applies the annotations, with the same code:

  • scan, live or --files, and the MCP server's scan tools;
  • the agent, in each status.targets[] entry of its ClusterReadiness (The agent);
  • /gate, to the posted manifests and, with ?cluster=, to the cluster's stored objects (The server gate);
  • the server's stored evaluations, into which the agent's pushes carry each object's annotations: the dashboard (its fleet cells and the cluster page's "N suppressed"), the fleet and team rollups, history, notifications, the auditor exports and GET /api/v1/clusters/{id}/report, read-time what-ifs included.

On the server, an accepted finding counts toward neither the score, the verdict, blockers nor a notification. Annotating a blocked cluster's last blocker sends became-ready; a new object that arrives already annotated sends no new-blocker; removing the annotation makes the finding a new-blocker again. GET /report lists the accepted finding in suppressed, with source: annotation and the reason, and /findings lists only the open ones. The CSV export has one suppressed row per accepted finding (its severity, what accepted it, the reason and the objects, in the detail) and counts them in the summary row; the HTML export lists them in a suppressed section. An audit never loses an accepted finding.

The server applies the annotations only, never the agent's spec.ignore, which the push does not carry (see The agent).

An annotation is as trusted as write access to its object: whoever can edit the object can accept its finding in every view, the fleet's included. The exports and GET /report name each acceptance with its reason.

An evaluation stored by an earlier server version picks the annotations up when it is next re-evaluated: on the next push that changes the inventory, when the knowledge base changes, or in the first background pass after UTC midnight.

Baselines

A baseline lets CI adopt the gate on an existing estate: the gate fails only on problems that are new since the baseline, and everything already known stays visible.

# Once, on the main branch: record what exists today.
upgradescope scan --files rendered/ --target 1.36 --fail-on never --write-baseline upgradescope-baseline.json

# On every PR: fail only on what the change adds.
upgradescope scan --files rendered/ --target 1.36 --baseline upgradescope-baseline.json

--write-baseline <path> writes the scan's JSON report, the same document that --output json produces, after suppression. --baseline <path> reads such a report. Any --output json report also works as a baseline. Each finding is then marked:

  • unchanged if the baseline has a finding with the same key, every object the current finding lists appears in it (matched by namespace, name and file, not line, so edits around an object do not matter), the finding has no more objects in total than the baseline had, and its severity has not risen since the baseline;
  • new otherwise.

The mark is baselineState in JSON and SARIF, (in baseline) after the title in the table, and a BASELINE N unchanged, M new line under the score. Only the baseline's findings count. Its suppressed entries do not, so when a suppression expires, the finding is new again.

Baseline matching relies on finding keys staying stable across runs and versions (see Finding keys).

How the gate decides

--fail-on blocker|warning exits 2 when, after suppression, a finding at or above the threshold remains that is not unchanged against the baseline. If none does, it still exits 2 when a required check was not assessed, unless --allow-incomplete is given: a new blocker could be hiding behind the gap, even when every known blocker is in the baseline. A --target that is not an upgrade of the cluster (a downgrade, the same minor, or a typo) exits 2 even with --allow-incomplete. --fail-on never always exits 0. Operational errors, including an invalid config file or baseline, exit 1.

Score and verdict exclude suppressed findings, but they include baseline findings. A baseline only changes what fails the gate. It does not change how ready the cluster is.

The server gate

POST /api/v1/gate on upgradescope serve suppresses as scan does, with the same code. It applies the upgradescope.basit.engineer/ignore annotations of the posted objects (and, with ?cluster=, of the cluster's stored objects), and the ignore rules of a .upgradescope.yaml sent, URL-encoded, in the config query parameter:

curl -sS --fail-with-body -X POST \
  "$SERVER/api/v1/gate?target=1.37&cluster=prod-eu-1&path=rendered.yaml" \
  --url-query "config@.upgradescope.yaml" \
  -H "Authorization: Bearer $READ_TOKEN" \
  -H "Content-Type: application/x-yaml" \
  --data-binary @rendered.yaml

--url-query needs curl 7.87 or later; with an older curl, append &config= and the file's URL-encoded text to the URL yourself. Rule file globs match the path parameter. The config is parsed and validated by the same code as scan --config: an invalid config (an unknown field, a rule without a reason, an expires that is not a date), one over 32 KiB, or config given twice is refused with 422 before anything is judged. 32 KiB is a few hundred rules.

The parameter travels in the URL, so two limits come first for a large config. URL-encoding expands YAML (a colon or a line break becomes three bytes, and so may each space of indentation), so a config under 32 KiB can exceed the server's 64 KiB limit on the request line and headers once encoded; Go's HTTP server then answers 431 with a plain-text body, not the JSON 422. A reverse proxy in front of serve usually allows far less: ingress-nginx's default large-client-header-buffers (8 KiB) answers 414 for a request line longer than that. Keep the config small, or raise the proxy's limit.

Suppressed findings count toward neither the verdict nor fail-on. With ?cluster=, what the manifests introduce is decided after suppression: a finding whose posted objects are all suppressed is not the pull request's, and objects the cluster already has at that key stay the cluster's. The JSON answer lists them in suppressed with a suppressedCount, and names expired rules and annotations without a reason in warnings. SARIF has no result for them (only a note that counts them; see Code scanning and accepted findings), JUnit lists them as skipped test cases, and GitLab Code Quality leaves them out, as scan does. With ?cluster=, a suppressed finding that is the pull request's only because the gate fails closed (for example a posted CRD that drops a stored version) is in the JSON suppressed list but not in the SARIF or JUnit answer.

The server gate has no baseline input. With ?cluster=, the cluster's stored state is its baseline: findings the cluster already has are tagged source: cluster and do not fail the gate, and only what the manifests introduce counts. Debt in the manifests themselves (a removed API that every render still contains) is accepted with scan --files --baseline (the GitHub Action's baseline input) or with ignore rules.

The agent: spec.ignore

The in-cluster agent reads the same rules from its ClusterReadiness object and applies them, along with the object annotations, on every tick:

apiVersion: upgradescope.basit.engineer/v1alpha1
kind: ClusterReadiness
metadata:
  name: cluster
spec:
  ignore:
    - key: eol-addon/ingress-nginx
      reason: migrating to Gateway API, tracked in PLAT-123
      expires: "2099-12-31"   # a date inside your migration window

Each status.targets[] entry counts suppressed findings in suppressed. They are not included in blockers, warnings and infos, in the score or in the verdict. Expired or invalid rules, and annotations without a reason, are listed in status.notAssessed and are not applied. The CRD schema already rejects a rule without a reason or with a malformed expires. The agent catches the rest (for example, both key and category). file never matches live objects.

The agent pushes the raw inventory to the server: each object's annotations, but not spec.ignore. So the server applies the object annotations and never spec.ignore, both in its stored evaluations (the dashboard, the fleet, history, notifications, exports and GET /report; see Where annotations apply) and in its gate, which applies the rules sent with each request instead (The server gate). A finding that spec.ignore alone accepts is suppressed in the ClusterReadiness status and open on the server. When the finding lists objects (API usage), annotating them accepts it in both; an add-on or version-skew finding lists none, so no server view can accept it yet.

Finding keys

Every finding has a key: its category, a slash, and a stable discriminator. The key never includes counts, so the same problem keeps the same key from run to run while the title changes (for example, "3 objects" becomes "2 objects"). Examples:

Finding Key
Removed or deprecated API removed-api/networking.k8s.io/v1beta1/Ingress, deprecated-api/core/v1/ComponentStatus (the core group is core). The same key is the removal blocker (the API is gone at the target) and the warning for a removal in the next minor
API the target does not serve yet (--files and the gate: "not served until 1.31") removed-api/<group>/<version>/<kind>/unserved, e.g. removed-api/networking.k8s.io/v1beta1/ServiceCIDR/unserved; a key without /unserved never accepts it (see One API, two keys)
Objects of a flagged API nothing can be attributed to deprecated-api/<group>/<version>/<kind>/authorship-unknown (info)
Deprecated API still requested deprecated-api-in-use/<group>/<version>/<resource>
EOL add-on eol-addon/ingress-nginx, or eol-addon/<id>/<cycle> for a release line
Version skew version-skew/kubelet-post-upgrade, version-skew/<component>-newer or -behind, version-skew/upgrade-path
Unknown built-in API unknown-api/<group>/<version>/<kind>
CRD version crd-version/unserved/<group>/<version>/<kind>, crd-version/deprecated/…, crd-version/stored-unserved/…
Managed-provider support support-lifecycle/<provider>/<minor>/<phase>, e.g. support-lifecycle/eks/1.34/extended; the phase is ending, extended or ended, so accepting one never accepts a later one (a key without the phase, as written before, matches nothing: see Upgrade)
Knowledge base behind the target kb-stale

--output json shows the key of every finding, and the SARIF output uses it as the rule id.

One API, two keys

An API in a manifest can be a problem in two opposite ways, and each has its own key, so accepting one never accepts the other:

  • Not served yet. The target is older than the release that introduces the API version (networking.k8s.io/v1beta1 ServiceCIDR is served from 1.31; --target 1.30 reads "not served until 1.31"). Applying the manifest fails today and works after the upgrade. Key: removed-api/networking.k8s.io/v1beta1/ServiceCIDR/unserved.
  • Removed. The target no longer serves the API version (the same ServiceCIDR is removed in 1.37; --target 1.37 reads "removed in 1.37"). Key: removed-api/networking.k8s.io/v1beta1/ServiceCIDR.

A rule written for "apply this once the cluster is on 1.31" (the first key) therefore stops applying at --target 1.37, where the manifest fails for the opposite reason, and the removal blocker is back. A baseline written at --target 1.30 marks the removal at --target 1.37 as new for the same reason.

The warning for a removal in the next minor and the removal blocker itself share the key removed-api/<group>/<version>/<kind>: it is the same API about to go and gone. An ignore rule accepts a key whatever the finding's severity, so a rule for the key written while the finding is the warning (--target 1.36) goes on accepting the blocker at --target 1.37. To accept only the warning, give the rule an expires date before you move the target to the removal release; a baseline is stricter, since it marks a finding as new when its severity rises.

A rule with the key of an API whose finding is the not-served-yet one (without /unserved) matches nothing, and scan prints a warning naming the key to write. That warning is printed only while the finding exists: a bare-key rule that is waiting for a later target draws none, and when that target arrives it accepts the removal.

A baseline written by a release before the /unserved key held the not-served-yet blocker under the removal's key. Reading it, upgradescope drops those entries (it recognises them by the "is not served until" title), so the blocker comes back once as new under its own key; write the baseline again (--write-baseline) after you have decided to accept it.

Code scanning and accepted findings

GitHub code scanning does not read the SARIF suppressions property: it opens an alert for every located result at its level, whatever the gate decided. A result for an accepted finding would therefore show as an open alert in the Security tab (and on the pull request's diff) although the gate passed. So scan --output sarif, /gate?format=sarif and the Action's sarif-file leave suppressed findings out of the results altogether. Instead the run carries one note, "N findings accepted by ignore rules or annotations are not uploaded; see the JSON report", and run.properties.suppressed holds the count. The accepted findings, their reasons and expiry stay in the JSON report, the step summary and JUnit.

What does stay a result:

  • Baseline-unchanged findings. A finding the baseline already had is still a result, marked baselineState: unchanged, and code scanning opens an alert for it like any other. --baseline changes the gate, not what code scanning shows. To keep it out of the Security tab, accept the finding with an ignore rule or annotation instead.
  • Unsuppressed findings, as always.

To dismiss an alert that is already open, use the code-scanning alerts API or GitHub's advanced-security/dismiss-alerts action, which reads non-empty suppressions[] from a SARIF file you produce yourself; the SARIF written here does not carry any.

Who can turn the gate off

Everything on this page that makes a finding stop counting is a file in the repository being judged: the ignore rules, the annotations in the manifests and the baseline. In CI on a pull_request those files come from the pull request's own tree, so a pull request can suppress its own findings and the gate then passes. Nothing is hidden (the suppressed findings, their reasons and the rule's file are in the table, the step summary and the JSON, and the change is in the diff), but the gate does not stop an author who wants past it unless you pin its inputs:

  • Ignore rules. .upgradescope.yaml is found in the scan root, then at the repository root, so a pull request that adds one next to a manifest with a removed API turns exit 2 into exit 0. --config <path> (the Action's config) names the one file to read and stops that search.
  • Annotations. upgradescope.basit.engineer/ignore with ignore-reason on an object always applies; no flag turns it off. Review the suppressed table, or fail the job when a suppression came from an annotation (jq -e '[.suppressed[]? | select(.source == "annotation")] | length == 0' on the JSON report).
  • The baseline. --baseline <path> (the Action's baseline) reads whatever file it is given, so a pull request can commit a baseline that holds its own findings.

To pin the config and the baseline, take both from the base commit: check it out into another directory, copy the two files over the pull request's, and name them in the Action. The files must exist at the base (ignore: [] is a valid empty config), and a pull request that changes them is judged by the old rules, so accepting a finding takes a pull request of its own that changes only those files. The copy comes last, right before the gate, and removes the destination first (rm -f --): a pull request can commit .upgradescope.yaml as a symlink to a file an earlier step writes, a plain cp would write through it, and that step would then overwrite the trusted content. config names the copy, not trusted/..., because the config's file globs resolve relative to the config's directory. CODEOWNERS with required code-owner review on the two files and on .github/workflows/ covers what a checkout cannot, and the workflow can be edited by a pull request too:

on: pull_request
jobs:
  upgrade-gate:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v7            # the pull request (its merge commit)
        with:
          persist-credentials: false
      - name: Clear the path the base commit goes to
        run: rm -rf -- trusted               # a pull request may have committed one
      - uses: actions/checkout@v7            # the base commit, only the two files
        with:
          ref: ${{ github.event.pull_request.base.sha }}
          path: trusted
          sparse-checkout: |
            /.upgradescope.yaml
            /upgradescope-baseline.json
          sparse-checkout-cone-mode: false
          persist-credentials: false
      - run: helm template my-release ./chart --output-dir rendered
      - name: Take the config and baseline from the base commit, last
        run: |
          rm -f -- .upgradescope.yaml upgradescope-baseline.json
          cp trusted/.upgradescope.yaml trusted/upgradescope-baseline.json .
      - uses: abd-ulbasit/upgradescope@v0.3.0
        id: gate
        with:
          path: rendered
          target: "1.37"
          version: v0.3.0
          config: .upgradescope.yaml           # named, so no other config is looked for
          baseline: upgradescope-baseline.json

The CI gate page has the full trust table and the annotation guard.