Help us shape the future of Go! Take the 2026 Go Developer Survey

driftgate

module
v1.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 2, 2026 License: Apache-2.0

README

DriftGate

CI Release Coverage Go Reference License

A regression gate for HTTP APIs: prove that every route still returns the same response after a framework upgrade, a refactoring or a new feature.

DriftGate records a baseline, then reports drift after the API changes

DriftGate records how a running application responds (the baseline). After every change (a framework or language upgrade, a refactoring, a new feature) it restores the same data, sends the same requests and compares the responses with the baseline: status, headers and body, literally. Every difference (a drift) is reported with the request, the path in the response, its kind, and what it was and is now. It also records what each request changed in the database and reports a change of that behaviour as drift.

It is one static binary, works from the outside over HTTP against any backend, and needs nothing added to the application. Its primary use is local: checking each step of a framework upgrade on your machine before the pull request. Running it in CI is optional.

User documentation: docs/.

Why DriftGate

  • It compares literally, on the real application and its real database. 42 instead of "42" after a PHP upgrade is reported as $.entry.id: type string → int; a tool that compares by meaning, fakes the database or checks only an OpenAPI schema does not see it.
  • It sees changes to the data, not only to the response. A request that starts writing to another table, or a GET that starts changing a row, is drift even when the response is the same.
  • It asks nothing of the application or of production. No SDK, agent, proxy or production traffic. It finds the routes itself (Laravel, Symfony, Rails, Django, Spring Boot, Next.js, Nuxt and any framework that serves an OpenAPI description), proposes the request catalog, and fails CI on a new route without a check.
  • Personal data stays out of git. Responses with personal data stay encrypted on the machine that recorded them; the repository names them only by a keyed hash, and reports never show their values.

Results

  • Verified on two real upgrades of a Laravel API from 5.5 to 13 (622 and 494 requests), at every step to the final state.

  • Measured on a synthetic set of 622 requests with 66 MB of bodies (Linux, i7-11800H, medians):

    Step Time Memory
    capture, concurrency 1 627 ms 39 MB
    capture, concurrency 4 446 ms 71 MB
    compare 26.5 ms 22 MB
    seed, MySQL 8.4 6.6 s
    seed, PostgreSQL 16 2.6 s
    command start 1.74 ms
  • CI tests the database code on MariaDB 10.3, 10.6 and 11.4, MySQL 8.0 and 8.4 and PostgreSQL 13, 16 and 17, and runs the tests on Windows too.

  • Releases are signed with cosign (release verification).

Install

Release binaries (linux, darwin and windows on amd64 and arm64). Verify the signature of SHA256SUMS first, then the binary against it:

gh release download v1.0.1 --repo orangehill/driftgate \
  --pattern driftgate-linux-amd64 --pattern 'SHA256SUMS*'
cosign verify-blob --bundle SHA256SUMS.sigstore.json \
  --certificate-identity https://github.com/orangehill/driftgate/.github/workflows/release.yml@refs/tags/v1.0.1 \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  SHA256SUMS
sha256sum -c --ignore-missing SHA256SUMS   # macOS: shasum -a 256 -c --ignore-missing SHA256SUMS
install -m 0755 driftgate-linux-amd64 /usr/local/bin/driftgate

SBOMs, provenance and reproducible builds: security, release verification.

From source (the Go version in go.mod):

go install github.com/orangehill/driftgate/cmd/driftgate@latest
# or, in a checkout:
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o driftgate ./cmd/driftgate

A source build reports its version as dev.

Docker image, built from the Dockerfile (no image is published yet):

docker build -t driftgate .
docker run --rm -v "$PWD:/work" --network host driftgate run

The image holds only the binary (non-root, no shell). Commands that driftgate.yaml runs (seed, restore, php artisan …) must exist where DriftGate runs; for those, copy the binary into the application's image: COPY --from=driftgate /usr/local/bin/driftgate /usr/local/bin/.

Quick start

A new project: driftgate setup. In the project's folder, with the application running, it recognises the framework, reads the database and the address from the project's own settings (.env, appsettings.json, compose…), writes .driftgate/, makes the key and, after asking, the pristine copy of the database, then the first baseline, and says what to check by hand (setup). After every upgrade step, driftgate run compares with that baseline.

A worktree per change. .driftgate/ is not in git, so a worktree added with git worktree add starts without it: driftgate setup there takes the main worktree's project with its baseline and key, switches working: to the database and base_url to the address the worktree's .env names (after asking; --yes says yes), and makes no baseline of its own; driftgate run then compares the worktree's code with the main worktree's baseline (baselines).

driftgate init writes only the configuration (with the database found commented out), for the steps one by one. The steps below use the demo API in this repository, so every output shown is real.

go build -o /tmp/demoserver ./tools/demoserver     # in a checkout of this repository
/tmp/demoserver -addr 127.0.0.1:8920 -version v1 &  # the application
mkdir shop && cd shop
driftgate init --project shop
driftgate key create   # the key, in .driftgate/key
$ driftgate init --project shop
created .driftgate/driftgate.yaml, .driftgate/catalog.json and .driftgate/allowlist.yaml
no known project type found; add routes under discover: or write the catalog by hand
...
Everything DriftGate keeps is in .driftgate/, which git ignores (.gitignore).

Everything is in the project's .driftgate/ folder, which init adds to .gitignore (with --baselines git, driftgate.yaml and driftgate/ go into the repository instead, and the key into the macOS Keychain; see baselines). The demo is not a framework init knows, so tell DriftGate its address, its routes and how to reset its data. In .driftgate/driftgate.yaml set base_url: http://127.0.0.1:8920 and mode: evolve (so that changes can be accepted), and add:

state:
  api:                       # the demo keeps its data in memory; its reset is the restore
    restore: ["curl", "-sf", "-X", "POST", "http://127.0.0.1:8920/admin/reset"]
discover:
  - adapter: command         # any command that prints the routes as JSON
    command: ["cat", "routes-src.json"]
params:
  - name: id
    value: "12"

and routes-src.json in the project folder (shop/, where you run the commands; the command adapter runs there):

[
 {"method":"GET","path":"/api/news"},
 {"method":"GET","path":"/api/news/{id}"},
 {"method":"POST","path":"/api/faq","body":{"q":"?"}}
]

The first run makes the baseline. run finds the routes, generates the catalog, captures twice on the same restored state, and makes the baseline only when both captures agree:

$ driftgate run
== discover ==
3 routes (command 3)
  route-added   GET /api/news
  route-added   GET /api/news/{id}
  route-added   POST /api/faq
wrote …/shop/.driftgate/routes.json
...
== capture ==

captured 3 requests into run-5729dc0359a3
...
2 runs on the same state gave the same responses ✓

== baseline ==
baseline local made from two equal captures: …/shop/.driftgate/baselines/local

The capture is named after the checkout (run-<id>), so two worktrees of one project never share it. With --baselines git the last line also says to commit driftgate/.

Change something. Restart the demo as v2, which plants changes, and run again (exit code 1):

$ kill %1; /tmp/demoserver -addr 127.0.0.1:8920 -version v2 &
$ driftgate run
...
DRIFT  get-api-news-id
         header-extra    header:x-powered-by   → ["demo"]
         key-extra       $.entry.meta   → (private)
         type            $.entry.id  string → int
         value           $.entry.published_at  (private) → (private)

Groups:
  [1] 3 drifts in 3 requests: header x-powered-by is new
      GET /api/news  (+2 more routes)
...
capture:run-5729dc0359a3 against baseline:local: 3 compared, 0 passed, 3 drifted, 0 skipped, 0 observed

Values show as (private) because generated requests are stored as private by default; driftgate compare run --show-private shows them on your own machine.

Review and accept. review lists the drift groups with the exact accept command. Accepting updates the baseline and appends the reason to .driftgate/baselines/local/acceptances.jsonl:

$ driftgate review
...
$ driftgate accept 'get-api-*' post-api-faq --reason "v2 returns numeric ids and a meta object" --ref DEMO-1
accepted 3 requests into baseline local: get-api-news, get-api-news-id, post-api-faq
  header-extra header:x-powered-by
  ...
logged in .driftgate/baselines/local/acceptances.jsonl

The next run still reports one drift: v2 stamps a new FAQ entry with the current time ($.entry.created_at). A value that changes by itself goes on the allowlist, with its format and a reason, in .driftgate/allowlist.yaml:

body:
  - request: post-api-faq
    path: $.entry.created_at
    format: '\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}'
    reason: Creation time of the new FAQ entry; v2 sets it to the current time.
$ driftgate run
...
capture:run-5729dc0359a3 against baseline:local: 3 compared, 3 passed, 0 drifted, 0 skipped, 0 observed
...
No drift ✓

With baselines: local nothing of this goes into git: the baseline stays on this machine, for the checks before each pull request. To share it with a team or a CI job, driftgate baselines git moves it into the repository; then commit driftgate.yaml and driftgate/ (baselines). Exit codes: 0 clean, 1 drift, 2 configuration error, 3 environment error or an incomplete result.

GitHub Action

on: pull_request            # never pull_request_target
permissions:
  contents: read
  pull-requests: write      # the report as a pull-request comment
jobs:
  driftgate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # … start the application and its database …
      - uses: orangehill/driftgate@v1.0.1   # a release tag or a commit SHA
        with:
          version: v1.0.1   # download and verify the signed release binary; default: build from source
          args: --skip-private

The action runs driftgate run against the committed baseline (it never makes one in CI), keeps the exit code, and leaves the report as one updated pull-request comment, in the job summary and as a JUnit file. Full guide: CI with GitHub Actions.

Documentation

License

Apache 2.0, see LICENSE.

Directories

Path Synopsis
cmd
driftgate command
Command driftgate is a regression gate for HTTP APIs.
Command driftgate is a regression gate for HTTP APIs.
internal
allowlist
Package allowlist holds the values that may change on every request (time, tokens).
Package allowlist holds the values that may change on every request (time, tokens).
appconf
Package appconf reads the database and the address an application is set up with, from the files each technology keeps them in: .env, appsettings .json, application.properties, database.yml, env.php, settings.py, compose.yaml and the like.
Package appconf reads the database and the address an application is set up with, from the files each technology keeps them in: .env, appsettings .json, application.properties, database.yml, env.php, settings.py, compose.yaml and the like.
baseline
Package baseline guards the integrity of a baseline folder: manifest.json holds the SHA-256 of every file and the hash of the last line of the acceptance log, whose lines are chained by the hash of the line before.
Package baseline guards the integrity of a baseline folder: manifest.json holds the SHA-256 of every file and the hash of the last line of the acceptance log, whose lines are chained by the hash of the line before.
bisect
Package bisect finds the commit that introduced a drift: a binary search over the commits between a known good and a known bad state, run for every drift group on its own, with the outcome of each tested commit shared between the groups.
Package bisect finds the commit that introduced a drift: a binary search over the commits between a known good and a known bad state, run for every drift group on its own, with the outcome of each tested commit shared between the groups.
capture
Package capture sends the whole catalog and stores the responses as a snapshot set: restore of the state and a check of its fingerprint, prepare, read-only requests (in parallel, within rate limits) and a search for any of them that changed data, guards, another restore, and the sequences and requests that change data, one at a time, each with the changes it made to the state.
Package capture sends the whole catalog and stores the responses as a snapshot set: restore of the state and a check of its fingerprint, prepare, read-only requests (in parallel, within rate limits) and a search for any of them that changed data, guards, another restore, and the sequences and requests that change data, one at a time, each with the changes it made to the state.
catalog
Package catalog is the list of requests DriftGate sends: one entry is one request, and variants of a route are separate entries with their own ID.
Package catalog is the list of requests DriftGate sends: one entry is one request, and variants of a route are separate entries with their own ID.
chaos
Package chaos names the points where the crash tests (tools/e2e) stop a driftgate process: each is a moment at which a kill must leave the store, the baselines and the databases in a state the next command recovers from.
Package chaos names the points where the crash tests (tools/e2e) stop a driftgate process: each is a moment at which a kill must leave the store, the baselines and the databases in a state the next command recovers from.
cli
Package cli implements the driftgate command line.
Package cli implements the driftgate command line.
compare
Package compare finds every difference (drift) between a baseline response and a current one.
Package compare finds every difference (drift) between a baseline response and a current one.
config
Package config reads driftgate.yaml and locates the project's files (Layout): with baselines: local all of them in the project's .driftgate/ folder, which git ignores; with baselines: git the driftgate/ folder in the repository and the local store outside it.
Package config reads driftgate.yaml and locates the project's files (Layout): with baselines: local all of them in the project's .driftgate/ folder, which git ignores; with baselines: git the driftgate/ folder in the repository and the local store outside it.
credential
Package credential tells names and values that hold credentials: passwords, tokens, keys, signatures.
Package credential tells names and values that hold credentials: passwords, tokens, keys, signatures.
data
Package data makes copies of a database that are safe to share with CI: every column of every table has a rule (classify.yaml), personal values are replaced deterministically under a project key, and the copy goes to another database and/or a byte-identical SQL dump.
Package data makes copies of a database that are safe to share with CI: every column of every table has a rule (classify.yaml), personal values are replaced deterministically under a project key, and the copy goes to another database and/or a byte-identical SQL dump.
data/files
Package files makes placeholders for uploaded files, so that a copy of an application's storage carries none of the original content.
Package files makes placeholders for uploaded files, so that a copy of an application's storage carries none of the original content.
dbdsn
Package dbdsn reads the DSNs of MySQL/MariaDB and PostgreSQL databases the way their drivers do, for the state components and the data module alike.
Package dbdsn reads the DSNs of MySQL/MariaDB and PostgreSQL databases the way their drivers do, for the state components and the data module alike.
dbx
Package dbx holds what the state components (internal/state) and the data module (internal/data) both do with MySQL/MariaDB and PostgreSQL servers: connecting, quoting names, setting up a session, telling MariaDB from MySQL, reading SHOW CREATE, filtering PostgreSQL's system schemas, and checking that a database is on a host DriftGate may rewrite.
Package dbx holds what the state components (internal/state) and the data module (internal/data) both do with MySQL/MariaDB and PostgreSQL servers: connecting, quoting names, setting up a session, telling MariaDB from MySQL, reading SHOW CREATE, filtering PostgreSQL's system schemas, and checking that a database is on a host DriftGate may rewrite.
demo
Package demo is a small API for testing DriftGate end to end.
Package demo is a small API for testing DriftGate end to end.
discover
Package discover finds the routes of an application: from Laravel's route list, an OpenAPI specification, the files of a Next.js or Nuxt project, or any command that prints them as JSON.
Package discover finds the routes of an application: from Laravel's route list, an OpenAPI specification, the files of a Next.js or Nuxt project, or any command that prints them as JSON.
generate
Package generate proposes catalog entries for the routes of the inventory: parameter values from the configuration, the pristine database or earlier responses, and one entry per template (ok, not-found, json…).
Package generate proposes catalog entries for the routes of the inventory: parameter values from the configuration, the pristine database or earlier responses, and one entry per template (ok, not-found, json…).
glob
Package glob matches request IDs, JSON paths and routes against simple patterns: '*' matches any run of characters (including none), '?' matches one character, and everything else, brackets included, matches itself.
Package glob matches request IDs, JSON paths and routes against simple patterns: '*' matches any run of characters (including none), '?' matches one character, and everything else, brackets included, matches itself.
har
Package har turns requests into catalog candidates: those of a HAR file (saved from the browser's developer tools while using the application) and those the recording proxy sees feed the same pipeline (Builder).
Package har turns requests into catalog candidates: those of a HAR file (saved from the browser's developer tools while using the application) and those the recording proxy sees feed the same pipeline (Builder).
httpx
Package httpx sends catalog requests and records the responses exactly: no automatic compression, no connection reuse, no proxies from the environment, and no connection to a host that is not allowed.
Package httpx sends catalog requests and records the responses exactly: no automatic compression, no connection reuse, no proxies from the environment, and no connection to a host that is not allowed.
infer
Package infer proposes allowlist formats: a regular expression that matches every observed value of something that changes on its own (a time, a token, an id), and as little else as it can.
Package infer proposes allowlist formats: a regular expression that matches every observed value of something that changes on its own (a time, a token, an id), and as little else as it can.
jsonx
Package jsonx parses JSON without losing anything a regression gate must see: key order, duplicate keys and the literal text of every number.
Package jsonx parses JSON without losing anything a regression gate must see: key order, duplicate keys and the literal text of every number.
lazy
Package lazy defers package-level setup to first use, so a command pays only for the patterns and tables of the code it runs (`driftgate version` compiles no regular expression).
Package lazy defers package-level setup to first use, so a command pays only for the patterns and tables of the code it runs (`driftgate version` compiles no regular expression).
pii
Package pii finds personal data in response bodies and headers before they are written as public (into git).
Package pii finds personal data in response bodies and headers before they are written as public (into git).
procx
Package procx starts the project's commands (restore, prepare, guards, command steps, token commands, bisect hooks) so that a time limit or an interrupt stops all of what they started, not only the command itself: a command runs in a process group of its own, and the whole group is killed when its context ends.
Package procx starts the project's commands (restore, prepare, guards, command steps, token commands, bisect hooks) so that a time limit or an interrupt stops all of what they started, not only the command itself: a command runs in a process group of its own, and the whole group is killed when its context ends.
proxy
Package proxy is the recording proxy of driftgate record: a reverse proxy that forwards every request to one service and hands each finished exchange to a callback, which turns it into a catalog candidate.
Package proxy is the recording proxy of driftgate record: a reverse proxy that forwards every request to one service and hands each finished exchange to a callback, which turns it into a catalog candidate.
report
Package report turns comparison results into output for people (text, and markdown for a pull-request comment) and for machines (JSON, JUnit XML for CI test reporters), with drifts grouped by signature and explained.
Package report turns comparison results into output for people (text, and markdown for a pull-request comment) and for machines (JSON, JUnit XML for CI test reporters), with drifts grouped by signature and explained.
routes
Package routes is the inventory of an application's routes (driftgate/routes.json), the routes excluded from checking (driftgate/exclusions.yaml), and coverage: which routes no catalog request reaches.
Package routes is the inventory of an application's routes (driftgate/routes.json), the routes excluded from checking (driftgate/exclusions.yaml), and coverage: which routes no catalog request reaches.
scan
Package scan scans a response body that would go into git, as public, for personal data and credentials, as a reader of the repository would see it: a capture's responses and the bodies of a baseline in git alike.
Package scan scans a response body that would go into git, as public, for personal data and credentials, as a reader of the repository would see it: a capture's responses and the bodies of a baseline in git alike.
secret
Package secret holds the key that encrypts private response bodies and the encryption itself (AES-256-GCM).
Package secret holds the key that encrypts private response bodies and the encryption itself (AES-256-GCM).
sheet
Package sheet reads and writes the spreadsheets APIs upload and export: CSV, and XLSX (Office Open XML).
Package sheet reads and writes the spreadsheets APIs upload and export: CSV, and XLSX (Office Open XML).
snapshot
Package snapshot stores captured responses: one record per request plus the body, kept by content.
Package snapshot stores captured responses: one record per request plus the body, kept by content.
state
Package state restores the data an application runs on and fingerprints it, so every capture starts from the same state and every change a request makes to the data is seen.
Package state restores the data an application runs on and fingerprints it, so every capture starts from the same state and every change a request makes to the data is seen.
storelock
Package storelock keeps two DriftGate processes from writing one local store at the same time: two worktrees of a project share the store, and a capture in one would otherwise move aside, replace or remove the other's capture of the same tag.
Package storelock keeps two DriftGate processes from writing one local store at the same time: two worktrees of a project share the store, and a capture in one would otherwise move aside, replace or remove the other's capture of the same tag.
upload
Package upload builds multipart/form-data bodies for catalog requests that send files, and the synthetic files they send when no real file is named: a minimal valid file of a kind (CSV, XLSX, PNG, PDF…), the same bytes every time, holding no data of anyone.
Package upload builds multipart/form-data bodies for catalog requests that send files, and the synthetic files they send when no real file is named: a minimal valid file of a kind (CSV, XLSX, PNG, PDF…), the same bytes every time, holding no data of anyone.
tools
demoserver command
Command demoserver runs the DriftGate demo API locally:
Command demoserver runs the DriftGate demo API locally:

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL