# Report checks from your CI

An [app](https://mn.staging.git.cafe/docs/integrations/apps.md) or a script with an access token can report CI results on a
commit as checks. A reported check shows on the commit's page and in a pull
request's checks panel next to GitCafe's own checks, and a
[ruleset](https://mn.staging.git.cafe/docs/repositories/repository-rulesets.md) can require it by name.
Commit statuses still work; checks add a stable identity, a summary,
annotations and re-runs. The operations are under **CI** in the
[REST API](https://mn.staging.git.cafe/docs/reference/api.md) reference.

## Permissions

Writing checks needs `repo:checks:write`; reading one back needs
`repo:checks:read`. Neither reaches code: an app with only
`repo:checks:write` cannot push or post commit statuses. An app asks for the
permission in its manifest's `permissions.autonomous.scopes`:

```json
{ "kind": "repo", "family": "checks", "level": "write" }
```

A personal access token that can write code can also write checks, and a
person still needs write access to the repository. An app's bot needs no role:
the permission and the repositories the installation selected are enough.

## Report a check

Use **Report a check run** with the commit's SHA and a key of your choice,
`build` here, and the check's whole state as the body:

```json
{
  "name": "depot/build",
  "status": "completed",
  "conclusion": "failure",
  "detailsUrl": "https://depot.example/builds/7",
  "externalId": "build-7",
  "reportedAt": "2026-10-23T12:05:00Z",
  "output": { "title": "1 test failed", "summary": "`test/a.ts` failed." },
  "annotations": [
    {
      "path": "test/a.ts",
      "startLine": 4,
      "endLine": 4,
      "level": "failure",
      "message": "expected 1, got 2"
    }
  ],
  "rerequestable": true
}
```

- **The commit** is the full 40-character SHA, and the repository must have
  it.
- **The key** (`build`) identifies the check for you:
  up to 100 letters, digits, `.`, `_`, `:` or `-`, starting with a letter or
  digit. Writing the same key on the same commit again updates that check;
  another app's checks never collide with yours.
- **`name`** is what people see and what a ruleset requires. Keep it stable
  across commits.
- **`status`** is `queued`, `in_progress` or `completed`. A completed check
  needs a `conclusion`: `success`, `failure`, `neutral`, `cancelled`,
  `skipped`, `timed_out` or `action_required`. `success`, `neutral` and
  `skipped` pass a required check; the rest fail it.
- **`suite`** (optional) groups checks: your checks on a commit with the same
  suite key, in the key's format, show together under it in the checks panel,
  with the suite's worst state. Another reporter's suite with the same key is a
  separate group. A suite only groups; rulesets still require checks by name.
- **`detailsUrl`** is an HTTPS link to your results; `externalId` is your own
  ID for the run, sent back with re-run requests.
- **`output`** has a `title` (255 characters) and a Markdown `summary` and
  `text` (65,535 characters each). Up to 50 **annotations** point at lines of
  files; columns are allowed on a single line.
- Each write replaces the whole check: leave out a field and it is cleared.
  `startedAt` and `completedAt` default to when GitCafe first saw the check
  running and completed. Only `rerequestable` keeps its value when you leave
  it out.

The response is `201` when it created the check and `200` otherwise, with the
stored check and an `outcome`:

| `outcome`       | Meaning                                                          |
| --------------- | ---------------------------------------------------------------- |
| `created`       | A new check.                                                     |
| `updated`       | The check now has what you sent.                                 |
| `unchanged`     | You sent what it already had; nothing changed.                   |
| `ignored_stale` | Your `reportedAt` is older than the stored one; nothing changed. |

Send `reportedAt` (when your system observed the state) so retries and
out-of-order deliveries cannot overwrite newer results. Without it, GitCafe
uses the time it receives the write. At the same `reportedAt`, a completed
check is not reopened. Times more than a minute in the future are refused.

## Deadlines

A check that stays `queued` or `in_progress` does not block a pull request
forever. Six hours after your last write that left it unfinished, GitCafe
concludes it `timed_out`, which fails a required check. Set
`timeoutMinutes` (1 to 4320, three days) on an unfinished write to choose
another bound. Each such write starts the bound again, so a long job stays
alive by reporting its state again before the deadline, even unchanged. The
stored check shows the current `deadlineAt`.

A timed-out check keeps its key. A later write from you, with a newer
`reportedAt`, replaces it as usual. A re-run request your app does not answer
within six hours also concludes `timed_out`.

GitCafe sends your app nothing when a check times out. Your app sees the
`timed_out` conclusion in the response to its next write for that key, or by
reading the check back.

From a shell or CI job, `cafe check report <key> --commit <sha> --name <name>
--status <status>` reports a check and `cafe check list` shows every check on a
commit ([CLI](https://mn.staging.git.cafe/docs/reference/cli.md#checks)).

## Report many checks at once

**Report check runs in a batch** writes up to 50 of your checks on one commit
in one request. Send each check's key and state:

```json
{
  "items": [
    { "key": "unit", "check": { "name": "depot/unit", "status": "queued" } },
    { "key": "lint", "check": { "name": "depot/lint", "status": "queued" } }
  ]
}
```

Each item is written as a single report would be, and the response lists one
`outcome` per item in the same order. An item GitCafe declines, such as one
with a time in the future, comes back as `refused` with an `error`, and the
others are still written. A malformed body, or a key that appears twice,
refuses the whole batch. So does a batch over 500 annotations, or over 1 MiB of
output and annotation text (paths included), in all: it fails with `check-batch-too-large` and
writes nothing, so split it.

**Get a reported check run** reads a check back with its annotations, and
**List checks for a commit** lists every check on a commit, from any source.

## Require a check

Add the check's `name` to a ruleset's **Require status checks** rule. A pull request
can merge only when every required name passes on its head commit. Commit
statuses and checks with the same name count together, and a failure from any
of them fails the requirement. Anyone who may write checks or statuses can
report under any name.

To accept a name only from your app, pin it: list it under the rule's `checks`
with your app's ID, as in `{"context": "ci/build", "appId": "int_…"}`. Then
only a check your app's installation reported through this API satisfies it;
a commit status, a person's check or another app's check with the same name
does not. On the ruleset page, under **Checks pinned to an app**, choose
**Add pinned check**, enter the name and choose the app from the apps installed
in the organization; organization admins see that list. The rulesets API and
`cafe repo ruleset create|edit --rule` take the same `checks` entries.

Only reports from the app's current installation count for a pin. After you
uninstall and reinstall the app, its new installation reports again and
replaces whatever the old one left on the commit.

## Re-run a check

When a check is `completed` and your app reported it with
`"rerequestable": true`, the commit and pull request show **Re-run** next to
it. People with write access to the repository can choose it, or use **Request a
re-run of a check**. Only people can: an app's bot, your own included, cannot
request a re-run. GitCafe then:

1. shows the check as queued, so a required check blocks merging, and
2. sends [`check.rerequested`](https://mn.staging.git.cafe/docs/integrations/webhooks.md#app-webhooks) to your app's
   installation, with the check's `id`, `key`, `headOid`, `name`,
   `externalId`, `detailsUrl` and earlier `conclusion`.

Answer by writing the same key on the same commit again, usually `queued`
then `completed`. Writes with a `reportedAt` from before the request do not
answer it. Asking again while a request is open sends nothing more. Both
happen or neither does: a re-run is refused, and the check left as it was,
when your app could not receive `check.rerequested` for this repository. That
is when the app is paused, suspended or uninstalled, the repository is no
longer selected, the permission is gone, or no webhook endpoint subscribes to
the event.

Only apps can offer re-runs: subscribe to `check.rerequested` in the manifest's
`capabilities.webhooks.events`, which needs a `repo:checks` permission. If your
app cannot re-run a check, leave `rerequestable` out or set it to `false`.
