Getting started

Try it

Run it against your own repository, without installing anything:

docker run --rm -v "$PWD:/workspace" ghcr.io/bare-devcontainer/decolint

By default only the correctness checks run — configuration that is invalid or does not behave as written. The security, pinning, and style checks are opt-in, and one config file away.

Install

Download a binary from the releases page. Release artifacts are signed and carry build provenance; see Verifying release artifacts.

Container image

docker run --rm -v "$PWD:/workspace" ghcr.io/bare-devcontainer/decolint [directory ...]

Images are published for linux/amd64 and linux/arm64 and tagged latest, <major>, <major>.<minor>, and <major>.<minor>.<patch>. They carry the same build provenance attestation as the binaries; see Verifying release artifacts.

Go

GOEXPERIMENT=jsonv2 go install github.com/bare-devcontainer/decolint/cmd/decolint@latest

GOEXPERIMENT=jsonv2 is required because decolint uses the still experimental encoding/json/v2 standard library package.

Set up your project

1. Run it

With no arguments the current directory is linted; name directories to lint those instead. Whatever you point it at is detected as a dev container definition, a Feature, or a Template — see What decolint lints.

Run it on the same file the demo above found nine problems in, and it comes back clean:

$ decolint
Config: none (defaults; run "decolint -init" to create .decolint.jsonc)
Linted 1 file:
  .devcontainer/devcontainer.json (devcontainer)

Found 0 errors and 0 warnings.

The header says why: no config file, so only the defaults are in effect. It also lists what was covered, which is the other thing worth checking when a run reports nothing.

2. Turn on the checks you want

The defaults are correctness alone. Everything else — the container privileges, the unpinned versions, the legacy properties — is off until you ask for it. Write a .decolint.jsonc in your repository root:

// .decolint.jsonc
{
  "categories": {
    "correctness": "error",
    "security": "error",
    "reproducibility": "error",
    "style": "error"
  }
}

That is the strictest setting. Start narrower if a whole category is more than you want today, and set individual rules where a category is close but not right:

{
  "categories": {
    "security": "error"
  },
  "rules": {
    "no-image-latest": "error",
    "require-non-root": "off"
  }
}

Every severity is error, warn, or off, and a rules entry beats its category. Run decolint -rules to see every rule with the severity your config gives it, or decolint -init to generate a config that lists all of them explicitly. The full list is under Rules, and everything the file accepts is under Config file.

3. Name your platform

Some rules only make sense on a particular platform — Codespaces ignoring bind mounts, VS Code pinning extension versions — and those stay off until you say which platforms you target:

{
  "platforms": ["vscode", "codespaces"]
}

4. Lint what actually runs

Your devcontainer.json is not the whole configuration. Features and the base image contribute their own, and the tooling merges it all together before the container starts. Take a file that is careful about all of it — pinned by digest, capabilities dropped, no new privileges:

// .devcontainer/devcontainer.json
{
  "name": "api",
  "image": "mcr.microsoft.com/devcontainers/go:1.24@sha256:8de3d5b3a3ce235671c7649f0b910414158a220d18cbd2714a4446cc0cc6acd3",
  "runArgs": ["--cap-drop=ALL"],
  "securityOpt": ["no-new-privileges"]
}

Linted as written it reports one problem. Merging replaces it with four:

$ decolint .
Config: .decolint.jsonc
Linted 1 file:
  .devcontainer/devcontainer.json (devcontainer)

.devcontainer/devcontainer.json:1:1: error: neither "remoteUser" nor "containerUser" is set, so the container defaults to running as root (require-non-root)
Found 1 error and 0 warnings.

$ decolint -merge .
Downloading image metadata(mcr.microsoft.com/devcontainers/go:1.24@sha256:8de3d5b3a3ce235671c7649f0b910414158a220d18cbd2714a4446cc0cc6acd3)
Config: .decolint.jsonc
Linted 1 file:
  .devcontainer/devcontainer.json (devcontainer)

.devcontainer/devcontainer.json:3:3: error: "securityOpt" overrides the default seccomp profile (no-seccomp-override)
.devcontainer/devcontainer.json:3:3: error: "securityOpt" contains "seccomp=unconfined", disabling the container's syscall filtering (no-seccomp-unconfined)
.devcontainer/devcontainer.json:3:3: error: extension "golang.Go" has no explicit version; pin a specific version (pin-extension-version)
.devcontainer/devcontainer.json:3:3: error: extension "dbaeumer.vscode-eslint" has no explicit version; pin a specific version (pin-extension-version)
Found 4 errors and 0 warnings.

The one reported without merging was wrong: the base image sets a non-root user, so require-non-root never applied. The four reported with merging are in nothing you wrote — the image disables seccomp and installs two unpinned VS Code extensions. Findings that come from merged content are reported at the property that pulled it in, here the image line.

Turn it on for good with "merge": true, or pass -merge per run. It fetches every referenced Feature and resolves the base image, so it needs network access; see Merging for what gets resolved and what does not.

5. Fix it, or say why not

When a finding is deliberate, suppress it in the configuration file itself so the reason lives next to the line:

{
  // decolint-ignore-next-line no-image-latest
  "image": "ubuntu:latest"
}

See Suppressing findings for file- and line-scoped directives.

Add it to CI

decolint exits 1 when it reports an error, which is all a CI job needs to fail. Add -format=github and the findings also appear as annotations on the pull request diff:

name: devcontainer
on: pull_request
permissions: {}

jobs:
  decolint:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - run: docker run --rm -v "$PWD:/workspace" ghcr.io/bare-devcontainer/decolint -format=github .

Only findings become annotations; the files that were linted go to a collapsed group in the run log, so a clean file does not annotate the diff. Warnings do not fail the build on their own; add -deny-warnings to make them count. See Exit codes.

To have findings tracked as alerts in the repository’s Security tab instead, emit SARIF and upload it. The upload writes to code scanning, so this job adds security-events: write to the permissions above:

  decolint-sarif:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - run: docker run --rm -v "$PWD:/workspace" ghcr.io/bare-devcontainer/decolint -format=sarif . > decolint.sarif
        continue-on-error: true # decolint exits 1 on findings; the upload must still run
      - uses: github/codeql-action/upload-sarif@v4
        with:
          sarif_file: decolint.sarif

A pull request from a fork gets a read-only token whatever the job asks for, so the upload cannot succeed there; skip the job on fork pull requests, or run it only on pushes to your default branch.

Run decolint from the repository root, so the reported paths resolve to files in the repository.

Linting a Feature or Template

If you publish a Feature or a Template, the mistakes that cost the most are the ones your consumers find after you have shipped: an id that does not match its directory, a version that is not semver, an install.sh that was committed without its executable bit. Point decolint at the directory — these are correctness rules, so they need no configuration:

$ decolint src/go-tools
Config: none (defaults; run "decolint -init" to create .decolint.jsonc)
Linted 1 file:
  src/go-tools/devcontainer-feature.json (feature)

src/go-tools/devcontainer-feature.json:1:1: error: "install.sh" is not executable (mode 0644); run "chmod +x install.sh" (feature-install-script-not-executable)
src/go-tools/devcontainer-feature.json:2:9: error: id "gotools" does not match containing directory "go-tools" (id-dir-mismatch)
src/go-tools/devcontainer-feature.json:3:14: error: version "1.0" is not a valid semantic version (see https://semver.org/) (invalid-semver)
Found 3 errors and 0 warnings.

A Template directory is linted the same way, and the dev container configuration the Template ships is linted along with it, including its ${templateOption:...} references.