Skip to content

Adopting vlotpipe gradually

Five ways to wire vlotpipe in, roughly ordered from lowest-friction to most enforced. None of them require the others — pick where a team actually is today, not where the ideal end state is. A common path: start with CI annotations only (visibility, no gate), add the pre-commit hook once people are used to seeing findings, only then switch check from advisory to a required status check. Once check is a real gate, select is what makes that transition gradual too — gate on a small, currently-clean set of rules first, rather than the whole rule set at once.

1. CI annotations (visibility, zero extra setup)

Every scan/check run already knows the location of every finding — --format github and --format azure-devops just express that as the platform's own native annotation syntax instead of plain text, so findings show up as inline comments directly on the "Files changed" / "Files" tab of a pull request. No bot, no app installation, nothing to grant permissions to:

# GitHub Actions
- run: vlotpipe check . --format github
# Azure Pipelines
- script: vlotpipe check . --format azure-devops

Even better: leave --format off entirely. Both scan and check detect the CI environment automatically (GITHUB_ACTIONS/TF_BUILD, the variables each platform sets on every run) and switch to the native annotation format on their own — an explicit --format always wins, this only fills in when the flag is omitted. A step that already runs vlotpipe check . starts producing inline annotations with no change at all.

Azure's logging commands only define two issue types (error/warning, no "notice") — info-severity findings map to warning, the less severe of the two, rather than being silently dropped.

2. A composite GitHub Action

action.yml at the repo root wraps install + run + annotate into one step:

- uses: vlotra/vlotpipe@v1        # pin to a tag once this repo has releases
  with:
    fail-on: blocker              # blocker | warning | info | never

fail-on is deliberately a separate concept from severity (what gets reported): severity: info shows everything but fail-on: blocker only fails the build on a blocker, which is the useful combination for "tell me everything, but don't block the merge on the small stuff" — also why fail-on: never exists, for rolling this out without gating anything on day one. Under the hood this runs a second, silent JSON pass and counts findings at or above the threshold itself, since vlotpipe check's own exit code only distinguishes "any blocker" from "none" — not granular enough for fail-on: warning/info on its own.

3. pre-commit framework hook

If the repo already uses pre-commit for other tools (black, ruff, prettier, ...), this is a two-line addition to .pre-commit-config.yaml:

repos:
  - repo: https://github.com/vlotra/vlotpipe
    rev: v0.1.0  # pin to a tag once this repo has releases
    hooks:
      - id: vlotpipe          # check only
      # - id: vlotpipe-fix     # check, but auto-apply --fix first
      # - id: vlotpipe-format  # format --check — surgical indent-only by default, see below

Defined in .pre-commit-hooks.yaml. Only lints the files actually staged in that commit (pre-commit passes them as arguments — vlotpipe scan/check already accepts an explicit file list, nothing hook-specific was needed on the CLI side), so this stays fast regardless of repo size. vlotpipe-fix is the closest thing to "just make it pass" — it applies --fix's safe, mechanical edits before checking, so most of what it would have blocked on never reaches the commit at all.

4. A raw git hook (no framework dependency)

For a repo that doesn't want a pre-commit framework dependency just for this, scripts/pre-commit is a plain bash script doing the same thing — stage-aware, fast, and self-contained:

cp scripts/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

It warns and lets the commit through if vlotpipe isn't on PATH, rather than hard-blocking a teammate who hasn't installed it yet — the comment in the script marks exactly where to change that to a hard exit 1 once a team has standardized on having it installed.

5. Pushing to a dashboard

Every other option above is local: a finding gets shown or gates a build in the one repo/run it was found in. report.to in .vlotpipe.yml (or --report-to) is different — it POSTs the complete, unfiltered finding set to a configurable endpoint after every scan, for a fleet-wide dashboard aggregating across a team's repos and branches. "Complete" is deliberate: the push is never narrowed by select or report.select — those control what gates a build or prints locally, but the dashboard's entire value is seeing everything, even from a repo whose local gate is scoped down to three rules.

# .vlotpipe.yml
report:
  to: "https://dashboard.example.com/api/ingest"

Resolved in this order, first non-empty wins — the URL is fine to commit, so this exists mainly for environments that want to override it per-invocation without editing the repo:

  1. --report-to <url>
  2. VLOTPIPE_REPORT_TO (env var)
  3. report.to: in .vlotpipe.yml

The auth token has no config-file option at all. .vlotpipe.yml is a committed file, and a bearer token hardcoded into it is exactly the SEC002 pattern vlotpipe itself flags in pipeline YAML — the tool doesn't reproduce that mistake in its own config. Set VLOTPIPE_REPORT_TOKEN (env var only); if set, it's sent as Authorization: Bearer <token>. If unset, no Authorization header is sent at all — some endpoints on a private network may not need one.

A failed push (unreachable host, non-2xx response) is printed as a warning: on stderr and never fails the scan or changes check's exit code — an optional, separately-operated dashboard being briefly down must never break the actual lint gate.

The payload:

{
  "repo": "origin's remote URL, best-effort from git — empty if not a git repo",
  "branch": "current branch, best-effort from git",
  "commit": "current commit, best-effort from git",
  "scanned_at": "RFC 3339 timestamp",
  "files_scanned": 12,
  "violations": [ /* every finding — full Violation objects, same shape as --format json */ ],
  "fingerprints": [ /* one entry per job with enough steps to fingerprint, see below */ ]
}

fingerprints is the input to cross-repo duplicate-job detection (see docs/adr/0004-duplicate-job-fingerprinting.md): a structural signature (internal/fingerprint, simhash-based) plus a file/job pointer, deliberately never the job's actual step content — a job's uses/run text never leaves the scanning machine, only enough to let a central service say "this job matches one found elsewhere" and point at both locations. vlotpipe scan/check already print every near-duplicate cluster found in a scan — each member's exact path:line and job name, entirely offline, no dashboard required — when two or more scanned jobs are near-duplicates of each other; this payload field is what lets that same comparison happen fleet-wide, across every repo in an org, instead of one scan at a time.

There's no real dashboard server yet to push to — this defines the client contract for one that's a separate, later project (see the vlotpipe-dashboard prototype in the sibling repo, which today pulls data via vlotpipe scan + the GitHub API rather than receiving a push, and its own docs/adr/0001-push-ingestion-contract.md, which commits to this exact payload shape for whenever an ingestion endpoint gets built). See also docs/adr/0001-selective-enforcement-and-report-push.md for the design reasoning on this side. Point --report-to at any endpoint that accepts this shape — internal tooling, a webhook-to-spreadsheet integration, a real dashboard once one exists — the client side doesn't care what's on the other end.

A note on .vlotpipe.yml and file-list invocations

Both the pre-commit hook and the raw git hook invoke vlotpipe with an explicit list of staged files rather than a directory — vlotpipe scan .github/workflows/ci.yml, not vlotpipe scan .. .vlotpipe.yml resolution handles this correctly: it walks upward from wherever scanning started (the same way git/eslint/prettier find their own config) until it finds either a .vlotpipe.yml or a .git directory marking the repo root, so the repo's real exception list still applies even though the file being linted is several directories below it.