For the complete documentation index, see llms.txt.

Chainguard Checks configuration reference

Reference for the .chainguard/ci.yaml file that defines Chainguard Checks: file layouts, packages, commands, network access, conditions, triggers, resources, and environment variables.
  26 min read

You define your checks in YAML configuration files in your repository’s .chainguard/ directory. This page describes the file format: where the files go, the packages and commands each check gets, and the options that control when and how checks run. For variations, inputs and outputs, services, and caches, refer to Variations, inputs, services, and caches.

The same files also set up Chainguard Workspaces sessions, and run-ci in a session runs the checks they define.

File layouts

Chainguard Checks reads its configuration from one of two layouts. A repository uses one or the other, not both.

LayoutUse it when
A single .chainguard/ci.yaml fileYou’re getting started, or all your checks share one set of packages.
A .chainguard/ci/ directoryYou want to split checks into several files, or give checks different environments.

In the directory layout, each <name>.yaml file in .chainguard/ci/ holds a group of checks, and the file’s name becomes part of each check’s name on GitHub. For example, a test check in .chainguard/ci/go.yaml appears as presubmit / go / test. Checks in the single-file layout appear as presubmit / ci / <check>. The directory layout can also hold the following:

  • env/<name>.yaml files, each defining a named environment that checks can share, and their .lock files. A check that names no environment uses env/default.yaml.
  • resources.yaml, which replaces the built-in resource classes with your own. Refer to Define your own resource classes.
  • <name>.md files that explain checks in more detail. Chainguard Checks never reads them as configuration. Refer to Describe a check.
  • services/<name>/<name>.yaml files, each defining a service, with any files the service needs beside it. Refer to Services.

Either layout can declare workload identities and cache pools in .chainguard/ci/identities.yaml. Refer to Workload identities and trust and Set up cache pools.

Chainguard Checks reads configuration only from files with the .yaml extension, so a .chainguard/ci.yml file doesn’t turn it on. The following table shows how each layout treats the files in .chainguard/:

FileSingle-file layoutDirectory layout
.chainguard/ci.yamlRequiredMakes the configuration invalid
.chainguard/ci/identities.yamlAllowedAllowed
Other files in .chainguard/ci/Make the configuration invalidAllowed if they’re among the files listed earlier. Any other file, such as a .yml file, makes the configuration invalid.
.chainguard/develop.yamlAllowed, unless it has a checks keyAllowed
.chainguard/actions.yaml, .chainguard/images.yaml, and .chainguard/source.yamlIgnoredIgnored
.chainguard/README.md, a .yml file, or another file that isn’t a .yaml or .lock file, directly in .chainguard/Makes the configuration invalidIgnored

In the directory layout, a .yaml file directly in .chainguard/ with single-file layout keys, such as version or checks, also makes the configuration invalid.

A .chainguard/develop.yaml file sets up Chainguard Workspaces sessions. Chainguard Checks runs nothing from it, so changing it doesn’t change your checks. Put checks in .chainguard/ci.yaml or .chainguard/ci/ instead. Sessions don’t read .chainguard/ci/env/develop.yaml, which is an environment like any other that a check can name with environment: develop. A pull request that changes .chainguard/develop.yaml also gets a Chainguard Checks / develop-environment check, as described in How results appear.

Most examples on this page use the single-file layout. The keys inside each check are the same in both.

A complete example

The following .chainguard/ci.yaml file uses the most common options:

.chainguard/ci.yaml
version: 1
environment:
  packages: [bash, git, go, make]
checks:
  vet:
    cmd: go vet ./...
    network:
      egress: [proxy.golang.org, sum.golang.org]
  test:
    cmd: go test ./...
    timeout: 15m
    network:
      egress: [proxy.golang.org, sum.golang.org]
  lint:
    script: |
      gofmt -l . > /tmp/unformatted
      cat /tmp/unformatted
      test ! -s /tmp/unformatted
    advisory: true
  build:
    steps:
      - name: compile
        cmd: [go, build, ./...]
      - name: package
        cmd: make dist
    needs: [test]
    network:
      egress: [proxy.golang.org, sum.golang.org]
  docs:
    cmd: make docs
    when:
      changed: ["docs/**"]

This file installs four packages in every check’s VM and defines five checks:

  • vet runs go vet, and can reach the Go module proxy and checksum database to download modules.
  • test runs go test, can reach the same hosts, and fails if it runs longer than 15 minutes.
  • lint runs a shell script. Because it’s advisory, it reports its result but never blocks merging.
  • build runs two steps in order, can reach the Go module hosts, and runs only after test passes.
  • docs runs only when a pull request changes a file under docs/.

The rest of this page describes each option.

Top-level keys

A configuration file has the following top-level keys:

KeyRequiredDescription
versionYesThe format version. Must be 1.
environmentYes, in the single-file layoutThe packages and variables every check in the file gets. Refer to Environment.
resourcesNoThe default resource class for the file’s checks. Refer to Resources.
checksYesA map from each check’s name to its definition.
plansNoNamed commands that compute a check’s foreach variations during the run. Refer to Compute variations during a run.
requested_checks_agentNooff (default), comment, or agent. Turns on an agent that requests checks for each pull request. Refer to Let an agent request checks.

Chainguard Checks refuses any other top-level key with an error that names the key. That includes GitHub Actions keys such as jobs, on, and name.

Environment

The environment key sets up the VM each check runs in:

KeyDescription
packagesA list of packages to install, such as go or nodejs-22.
varsA map of environment variables to set for every check.
servicesServices, such as a database, to start in each check’s VM before the check’s commands run. Refer to Services.
cachesCaches that carry build output from earlier runs into each check’s VM. Refer to Caches.

Every check’s VM starts from a minimal base with BusyBox, GNU tar, and nothing else. Anything a check needs, including git, bash, make, curl, or a language toolchain, must be in packages. Package names often include a version, such as go-1.24, nodejs-22, or python-3.13. To pin a more specific version, add a version constraint after a tilde, such as go-1.24~1.24.3.

To find a package’s name, use the cg-apk MCP server, which looks up APK packages.

In the directory layout, a check can name a different environment with its own environment key, either by name, such as environment: node for env/node.yaml, or as an inline mapping.

Check keys

Each entry under checks defines one check. The key is the check’s name. A check must have exactly one of cmd, script, or steps, and can have any of the other keys:

KeyDescription
cmdA command to run without a shell. Refer to Commands.
scriptA script to run with a shell. Refer to Commands.
shellThe shell for script. Defaults to sh -e. A check with cmd or steps can’t set it, but each step can set its own. Refer to The script key.
stepsA list of commands to run in order. Refer to Commands.
networkThe hosts the check can reach. Refer to Network access. Defaults to no network access.
timeoutHow long the check can run, as a duration such as 10m or 1h30m. Refer to Timeouts.
needsA list of checks in the same file that must pass before this one runs. Refer to Advisory checks and Run on request for limits.
whenConditions under which the check runs. Refer to Conditions.
triggerpresubmit, postsubmit, or a list of both. Refer to Triggers. Defaults to presubmit.
advisoryIf true, the check reports its result but never blocks merging. Defaults to false.
descriptionWhat the check covers and when it’s worth running. Refer to Describe a check.
docsThe name of a Markdown file beside the check file that explains the check in more detail. Directory layout only. Refer to Describe a check.
varsA map of environment variables for this check.
workdirThe directory to run in, relative to the repository’s root. Defaults to the root, which is /work in the VM.
inputsFiles to add to the check’s VM from another check’s output or from another repository. Refer to Inputs and outputs.
outputsDirectories that the check produces for later checks in the same run. Refer to Inputs and outputs.
resourcesThe check’s resource class. Refer to Resources.
foreachA list of variations to run the check once for each, such as one per package directory, or plans.<name> to compute the variations during the run. Refer to Run a check once per variation.
parallelismHow many foreach variations can run at once. Refer to Run a check once per variation.
userThe name of an account to run the check’s commands as, such as build. Defaults to root. Refer to Run as another user.
identityA workload identity for the check. Refer to Credentials.

Chainguard Checks refuses keys it doesn’t recognize, including GitHub Actions keys such as runs-on, env, if, and timeout-minutes. For some of them, such as if and on, the error names the key to use instead. Refer to Migrate from GitHub Actions for a full mapping.

Check names

A check’s name can use letters, digits, spaces, _, and -, and must start and end with a letter or digit. It can’t contain . or /. Two checks in the same file can’t have names that differ only in case, because request labels ignore case.

A check’s full name joins its file’s name and its own, such as ci/test or go/test. Each foreach variation’s full name adds the variation’s name, such as go/test/api. Every full name can be up to 128 characters. If a name has a character that isn’t allowed, the error says only that the name must fit the 128-character limit, so check its characters too.

Commands

A check runs a single command, a script, or a list of steps.

The cmd key

The cmd key runs one command without a shell. You can write it as a string or as a list of arguments:

checks:
  test:
    cmd: go test ./...
  test-short:
    cmd: [go, test, -short, ./...]

Chainguard Checks splits a string cmd into arguments on whitespace and runs it without a shell. Quotes and backslashes work the way they do in a shell, so cmd: find . -name '*.go' passes *.go to find as it is. Chainguard Checks refuses a string cmd that contains any of the following:

  • An unquoted |, &, ;, <, >, (, ), $, `, *, ?, [, ], {, or }.
  • An argument that starts with an unquoted # or ~.
  • A $ or a backtick inside double quotes.

For pipes, redirection, variables, or globs, use script, or run a shell yourself, such as cmd: [sh, -c, "go vet ./... && go test ./..."].

The script key

The script key runs a script with a shell. By default, the shell is sh -e, so the script stops at the first command that fails:

checks:
  fmt:
    script: |
      gofmt -l . > /tmp/unformatted
      if [ -s /tmp/unformatted ]; then
        cat /tmp/unformatted
        exit 1
      fi

To use a different interpreter, set shell to the interpreter’s package name, such as bash, python-3.13, or nodejs-22, and list that same package in the environment. The BusyBox sh is always available. Chainguard Checks passes the whole script to the interpreter as one argument:

InterpreterRuns the script with
BusyBox sh (default)sh -e -c
Bashbash -e -c
Pythonpython3 -c
Node.jsnode -e
Rubyruby -e
Perlperl -e

Neither sh -e nor bash -e sets pipefail, so a pipeline succeeds whenever its last command does. For example, go test ./... | tee test.log passes even when tests fail. To make a pipeline fail when any of its commands fails, set shell: bash and start the script with set -o pipefail, or use a cmd such as [bash, -eo, pipefail, -c, "go test ./... | tee test.log"]. Either way, list bash in the environment’s packages.

The steps key

The steps key runs a list of commands in order, in the same VM, and stops at the first one that fails. Each step has a cmd or a script, and can also have the following keys:

KeyDescription
nameA name that appears in the check’s results.
shellThe shell for the step’s script, the same as a check’s shell.
idAn identifier for the step in the check’s logs.

For example:

checks:
  build:
    steps:
      - name: compile
        cmd: go build ./...
      - name: test
        cmd: go test ./...
      - name: smoke test
        script: ./bin/app --version | grep -q "app"

All steps share the check’s network access and timeout.

Network access

A check has no network access unless it has a network key. To let a check reach specific hosts, list them under network.egress:

checks:
  test:
    cmd: npm test
    network:
      egress:
        - registry.npmjs.org
        - "*.githubusercontent.com"
      policy: deny

Each entry in egress can be one of the following:

  • A hostname, such as registry.npmjs.org, in lowercase and without a scheme or port.
  • A wildcard for a domain’s subdomains, such as *.githubusercontent.com. A wildcard doesn’t match the domain itself, so list githubusercontent.com too if the check needs it. A wildcard can’t cover a whole top-level domain, such as *.com.
  • An IPv4 address or CIDR range. IPv6 isn’t supported.
  • *, which allows any host.

An entry with a scheme or a port, such as https://registry.npmjs.org or registry.npmjs.org:443, passes validation but matches nothing. If you include a network key, egress must list at least one entry. To keep a check off the network, leave out network entirely.

The policy key controls what happens when the check tries to reach a host that isn’t listed:

PolicyWhat happens
deny (default)Chainguard Checks refuses the connection, and the check keeps running.
destroyChainguard Checks stops the check’s VM, and the check fails.

Whatever a check allows, it can never reach cloud metadata endpoints, loopback addresses, or private IP ranges.

To see which hosts a check tried to reach and whether Chainguard Checks allowed each one, open the check’s run page from its Details link. Refer to A check can’t reach a host.

Conditions

The when key limits when a check runs. A check without when runs on every pull request. An empty when makes the configuration invalid, so to run a check every time, leave out when.

Run when files change

To run a check only when a pull request changes certain files, list glob patterns under when.changed:

checks:
  docs:
    cmd: make docs
    when:
      changed: ["docs/**", "mkdocs.yml"]

changed must list at least one pattern. Patterns match file paths as follows:

  • A pattern is relative to the repository’s root, such as docs/** or cmd/*/main.go. A pattern that starts with / or ./ never matches.
  • * matches any characters except /, and ? matches any one character except /. A set in brackets, such as [abc] or [a-z], matches one character from the set.
  • ** matches any number of directories, including none, but only as a whole segment between slashes. docs/** matches every file under docs/, and **/*.go matches every Go file in the repository. **.go works like *.go, so it matches only Go files at the root.
  • !, braces such as {a,b}, and + have no special meaning, and there’s no when.unchanged, so you can’t exclude files. List the paths that should run the check instead.

chainctl checks validate refuses a malformed pattern, such as one with an unclosed [, but not a pattern that can never match, such as /docs/**.

For a pull request, Chainguard Checks compares the pull request’s changes against its base branch. For a run after merging, it compares the new commit against its first parent, as described in Run checks after merging.

Run when an expression is true

To run a check based on the pull request, write a Common Expression Language (CEL) expression under when.expr. The expression can read the following fields:

FieldDescription
context.triggerThe run’s trigger. Compare it to the unquoted constants presubmit and postsubmit, such as context.trigger == postsubmit.
context.github.pull_request.numberThe pull request’s number.
context.github.pull_request.titleThe pull request’s title.
context.github.pull_request.bodyThe pull request’s description as it was when the run started, or an empty string if it has none.
context.github.pull_request.html_urlThe pull request’s URL on GitHub.
context.github.pull_request.draftWhether the pull request is a draft.
context.github.pull_request.labelsThe pull request’s labels.
context.github.pull_request.head_refThe pull request’s branch name.
context.github.pull_request.head_repositoryThe repository the pull request’s branch is in, which differs from yours for a pull request from a fork.
context.github.pull_request.author.loginThe GitHub login of the pull request’s author.
context.github.pull_request.author.idThe numeric GitHub account ID of the pull request’s author.
context.github.pull_request.author.typeThe type of the author’s GitHub account: User, Bot, or Organization.

For example, this check skips draft pull requests:

checks:
  e2e:
    cmd: make e2e
    when:
      expr: "!context.github.pull_request.draft"

A run after merging has no pull request, and reading a context.github.pull_request field in that run is an error that fails the run. For a check that also runs after merging, guard the field with has(), such as expr: "!has(context.github.pull_request) || !context.github.pull_request.draft".

If you set both changed and expr, the check runs only when both are true. To combine changed files with other conditions in other ways, call changed() in the expression with a list of patterns, which match files the same way as when.changed. For example, this check runs when a pull request changes a file under docs/ or has the docs label:

checks:
  docs:
    cmd: make docs
    when:
      expr: 'changed(["docs/**"]) || "docs" in context.github.pull_request.labels'

An expression can be up to 2,048 characters, and Chainguard Checks refuses one whose estimated cost to evaluate is over its limit.

Chainguard Checks evaluates conditions when it creates a run, against the pull request as it is then. If the run passes while a condition skipped a check, and you then change the pull request, such as its title or labels, the commit runs again. A passed run of a pull request that changes your Chainguard Checks configuration also runs again after such a change, whether or not a condition skipped a check. Refer to Reruns when a pull request changes.

A pull request’s author sets its draft, title, head_ref, and body fields, and anyone who can label it sets its labels. If a condition on one of these fields skips a blocking check, those people decide whether the check runs, and the summary check can pass without it. chainctl checks explain adds a note to each blocking check whose condition reads one of these fields.

Run on request

A check with when: {requested: true} runs only when someone asks for it. Use this for slow or expensive checks that most pull requests don’t need:

checks:
  e2e-full:
    cmd: make e2e-full
    description: The full end-to-end suite against every supported database. Run it for changes to the storage layer or to database migrations.
    when:
      requested: true

A check that runs on request follows these rules:

  • It must have a description that says what it covers and when it’s worth running.
  • requested can only be true, and the same when can’t also set changed or expr.
  • Its name can be at most 43 characters, so that its label fits GitHub’s 50-character limit. When two files in the directory layout have a check with the same name, the label includes the file’s name, such as ci/run:go/e2e-full, and the limit counts the file’s name and the / after it too.
  • A check that doesn’t run on request can’t need one that does, because it would then run only when the other check is requested.

To run the check on a pull request, add the label ci/run:<check>, such as ci/run:e2e-full. Refer to Run a check on request for details. A run after merging never runs a check on request.

Describe a check

A check’s description says what the check covers and when it’s worth running, in up to 1,024 characters.

In the directory layout, a check can also point to a longer explanation with docs, which names a Markdown file beside the check file:

.chainguard/ci/e2e.yaml
version: 1
checks:
  e2e-full:
    cmd: make e2e-full
    description: The full end-to-end suite against every supported database. Run it for changes to the storage layer or to database migrations.
    docs: e2e-full.md
    when:
      requested: true

The file, .chainguard/ci/e2e-full.md in this example, must be directly in .chainguard/ci/ and can be up to 1 MiB. Its name must end in .md, can use letters, digits, ., _, and -, and can be up to 128 bytes. If the file is missing or larger than 1 MiB, the configuration is invalid. Set docs on the check, not on a foreach entry. The single-file layout doesn’t support docs.

If you turn on requested_checks_agent, the agent reads each check’s description and docs to decide which checks to request. Refer to Let an agent request checks.

Advisory checks

A check with advisory: true reports its real result on the pull request, with (advisory) in its title, but never makes the Chainguard Checks (presubmit) summary check fail. Use it for a check you want to see but not enforce yet, such as a new linter.

A blocking check can’t need an advisory check, either with needs or by taking an input from it. If the advisory check failed, the blocking check wouldn’t run, and the summary check would fail. Make both checks advisory, or both blocking.

Triggers

The trigger key sets which events run a check:

TriggerWhen the check runs
presubmit (default)On each pull request, against the commit GitHub would create by merging it.
postsubmitOn each new commit on your default branch, after a change merges.

To run a check at both times, list both:

checks:
  test:
    cmd: go test ./...
    trigger: [presubmit, postsubmit]

Postsubmit results appear on the commit on your default branch, in a summary check named Chainguard Checks (postsubmit).

Timeouts

By default, a check has no time limit of its own. A whole run can take up to 50 minutes, and a check still running at that point fails. To fail a check sooner, set timeout to a duration, such as 10m or 1h.

Resources

By default, each check’s VM has 2 vCPUs, 8 GB of memory, and a 14 GB disk. To give a check more, set resources to one of the following classes:

ClassvCPUsMemoryDisk
medium416 GB40 GB
large832 GB40 GB
xlarge1664 GB40 GB

On pull requests, a class without a suffix can run on either CPU architecture, and usually runs on arm64. Each built-in class also has -amd64 and -arm64 variants, such as large-amd64, that pin the architecture. With the built-in classes, use an -amd64 class for a check that needs x86-64, such as one that downloads an x86-64 binary.

Chainguard Checks takes each check’s class from the first of these that sets resources: the foreach entry, the check, the check’s file, and env/default.yaml in the directory layout. If none of them sets resources, the check gets the default class.

Your organization’s allowance sets the largest class you can use. With the Starter allowance, the largest is medium. If any check in a run asks for large or xlarge, the whole run fails before any check starts, and the summary check shows Run failed.

In each check’s VM, /tmp is held in memory, and can hold up to a quarter of the VM’s memory: about 2 GB with the built-in default, and 4 GB with medium. Files in /tmp use memory that the check’s processes would otherwise have. Put large files, such as a virtual environment or build output, under /var/tmp or /work, which are on the VM’s disk.

Define your own resource classes

In the directory layout, a .chainguard/ci/resources.yaml file replaces the built-in classes with your own:

.chainguard/ci/resources.yaml
version: 1
resource_classes:
  default:
    cpus: 2
    memory: 8Gi
    disk: 20Gi
  big:
    cpus: 4
    memory: 16Gi
    disk: 40Gi
  big-amd64:
    cpus: 4
    memory: 16Gi
    disk: 40Gi
    platform:
      os: linux
      architecture: amd64

A check selects one of these classes by name, such as resources: big. The file follows these rules:

  • It must define default, which a check gets when nothing sets its resources.
  • It replaces the built-in classes, so medium, large, xlarge, and their -amd64 and -arm64 variants aren’t available unless you define classes with those names.
  • Each class needs cpus. memory and disk are whole numbers of Mi or Gi, such as 512Mi or 16Gi. A class without memory gets 4 GiB, and a class without disk gets 5 GiB.
  • Only platform pins a class to an architecture, with os: linux and architecture set to amd64 or arm64. A class without platform can run on either architecture, whatever its name.
  • Class names use lowercase letters, digits, and -, start with a letter, and can be up to 63 characters.
  • The single-file layout doesn’t support resources.yaml. A .chainguard/ci/resources.yaml file beside .chainguard/ci.yaml makes the configuration invalid.

Your organization’s allowance limits your own classes too. If any check in a run asks for more vCPUs, memory, or disk than the allowance allows, the whole run fails before any check starts.

Environment variables

Set environment variables with vars, either in environment for every check or on a single check:

environment:
  packages: [go]
  vars:
    CGO_ENABLED: "0"
checks:
  test:
    cmd: go test ./...
    vars:
      GOFLAGS: -tags=integration

In a check’s vars or a foreach entry’s vars, but not in environment.vars, a variable’s value can also come from the pull request, with a binding such as "${{ context.github.pull_request.number }}". The binding must be the variable’s whole value, and it reads the same fields as when.expr. Chainguard Checks fills in bindings when it creates the run:

  • A list, such as labels, becomes a JSON array, such as ["bug","docs"].
  • context.trigger becomes presubmit or postsubmit.
  • A run after merging has no pull request, so a binding to a context.github.pull_request field leaves the variable unset. To tell an unset variable from an empty one in a script, test it with ${VAR+set}.

A binding to context.github.pull_request.body works as follows:

  • It sets the variable to the pull request’s description as it was when the run started, line breaks included.
  • Only the check’s commands get the variable, not its services.
  • To pick up an edited description, use Re-run or push a new commit.
  • The variable can’t be CHAINGUARD_BASE_REVISION, GOOGLE_APPLICATION_CREDENTIALS, GITHUB_TOKEN, or GOFLAGS, which Chainguard Checks can set itself.
  • The description can be up to 65,536 bytes, and can’t contain a NUL character. A character outside ASCII takes more than one byte.

If a binding breaks these rules, the run fails, as described in The summary check says the run couldn’t be created or failed.

Chainguard Checks doesn’t set CI or any GITHUB_ variables. When a run has a base commit, Chainguard Checks sets CHAINGUARD_BASE_REVISION to that commit’s SHA. For a pull request, that’s the base branch commit that GitHub merged the pull request into. For a run after merging, it’s the new commit’s first parent. Your repository’s history is in the VM, so with git in your packages, a check can compare against it, such as with git diff "$CHAINGUARD_BASE_REVISION".

Report errors and annotations

A check can print special lines to its output that Chainguard Checks turns into annotations and summaries on the pull request, or uses to protect its log:

LineWhat it does
::error file=main.go,line=12::messageReports an error as an annotation on that file and line.
::warning file=main.go,line=12::messageReports a warning as an annotation on that file and line.
::notice file=main.go,line=12::messageReports a notice as an annotation on that file and line.
::sandbox-summary::, then ::end-sandbox-summary::Adds the Markdown between the two lines to the check’s output on GitHub. Refer to Add a Markdown summary.
::add-mask::valueHides value wherever it appears later in the check’s log.

An ::error, ::warning, or ::notice line with a file becomes an annotation at that level. Without a file, the line appears in the check’s output only. The file path must be relative to the repository’s root, not to the check’s workdir. A line can also set endLine, col, endColumn, and title. Without line, the annotation goes on line 1 of the file. Columns apply only to an annotation on a single line.

Chainguard Checks reads these lines only when :: starts the line, and only from the last 1 MiB of a check’s log, so a check with a long log should print them near the end. Chainguard Checks shows up to 50 annotations for each check, and annotations don’t change whether a check passes.

The annotation lines use the same format as GitHub Actions workflow commands, so many linters that support GitHub Actions output work without changes.

Add a Markdown summary

In place of the GITHUB_STEP_SUMMARY file in GitHub Actions, a check can add a summary in Markdown to its output on GitHub, such as a list of the problems it found. Print the summary between a ::sandbox-summary:: line and a ::end-sandbox-summary:: line:

checks:
  fmt:
    script: |
      gofmt -l . > /tmp/unformatted
      if [ -s /tmp/unformatted ]; then
        echo '::sandbox-summary::'
        echo 'These files need `gofmt`:'
        echo
        sed 's/^/- /' /tmp/unformatted
        echo '::end-sandbox-summary::'
        exit 1
      fi

A summary works as follows:

  • Each marker must be on a line of its own. The summary appears in the check’s output, above the end of the check’s log.
  • If a check prints several summaries, they appear together, in the order the check printed them.
  • A summary ends with the step that prints it. If the step ends before ::end-sandbox-summary::, the summary appears as far as it went, with a note.
  • HTML appears as text, except the <details>, <summary>, and <br> tags.
  • Links and images stay only if they point into your repository on GitHub, under https://github.com/<owner>/<repo>/, or to the run pages that a check’s Details link opens. Other links become plain text.
  • An @ mention in a summary doesn’t notify anyone.
  • GitHub limits the size of a check’s output, so Chainguard Checks cuts a summary longer than about 53 KiB and links to the full log. In a check with foreach, each variation’s summary can be up to 8 KiB.

Credentials

A check runs without credentials by default. Its /work directory holds your repository’s tracked files and history at the commit it tests, so most checks need none. Submodule directories are empty, and files that Git LFS tracks hold their pointer files instead of their content. During the beta, a check gets no GitHub credential, so it can’t fetch from private GitHub repositories, such as private Go modules or submodules, or private Git LFS content. To add files from a public repository, use a git input.

A check that must authenticate to Google Cloud or to Chainguard, such as to pull private images from cgr.dev, can use a workload identity. You declare workload identities in .chainguard/ci/identities.yaml, and a check requests one by name with identity. Workload identities need setup on the provider’s side as well. During the beta, contact your Chainguard account team to set them up, or contact Chainguard if you signed up yourself.

Unless identities.yaml on your default branch has a trust section, any open pull request can get an identity, including one from a fork. Refer to Workload identities and trust for the file’s format, which pull requests get an identity, and how to limit them.

Validate your configuration

To find mistakes in your configuration without running anything, run chainctl checks validate . from your repository’s directory. To see how Chainguard Checks interprets your configuration, run chainctl checks explain. Refer to Validate your configuration offline for details.

More examples

Node.js

This file installs Node.js 22 and npm, installs dependencies, and runs the tests:

.chainguard/ci.yaml
version: 1
environment:
  packages: [nodejs-22, npm]
checks:
  test:
    steps:
      - name: install
        cmd: npm ci
      - name: test
        cmd: npm test
    network:
      egress: [registry.npmjs.org]

The install and test steps run in the same VM, so the packages that npm ci installs are available to npm test. If your project uses packages from another registry, add that registry’s host to egress.

Python

This file installs Python 3.13 and pip, installs the project’s requirements, and runs pytest:

.chainguard/ci.yaml
version: 1
environment:
  packages: [python-3.13, py3.13-pip]
checks:
  test:
    script: |
      python3 -m venv /var/tmp/venv
      . /var/tmp/venv/bin/activate
      pip install -r requirements.txt
      pytest
    network:
      egress: [pypi.org, files.pythonhosted.org]

The script creates a virtual environment so that pip installs packages for the check alone. It puts the environment in /var/tmp, on the VM’s disk, because /tmp is held in memory. Both pypi.org and files.pythonhosted.org are in egress, because pip reads the package index from the first and downloads files from the second.

Last updated: 2026-10-13 14:50