Skip to content

rowshape in CI

A CI job needs three things from rowshape: a verdict it can branch on, a way to decide whether WARN blocks the merge, and findings a reviewer can see on the pull request. This page covers all three, then puts them in one workflow.

The job never needs a production credential. It validates against the committed rowshape.yaml in a disposable Postgres — a services: container is enough — and validate refuses outright to run against the host the fixture was pulled from.

rowshape validate reports its verdict as the process exit code. The codes are part of the public contract and do not change:

CodeVerdictMeaning
0PASSNo findings.
1FAILA finding proved the migration unsafe on this data.
2WARNFindings exist but none is an error: it works, and it costs something.
3—Tool error: rowshape could not produce a verdict (bad fixture, unreachable database, refused target).

A tool error is deliberately not a FAIL. Exit 3 means “no answer”, and a pipeline should treat it as a broken job, not as a verdict about the migration.

WARN covers things like a full-table rewrite under ACCESS EXCLUSIVE (RS-LOCK-001) — the migration succeeds, and production pays for it. Whether that should block a merge is your call, and it is the one gating knob there is:

  • CLI: --warn-fail makes a WARN-only verdict exit 1 instead of 2. Without it, a plain set -e script already fails on WARN, because 2 is non-zero.
  • GitHub Action: WARN passes the job by default. warn-as-fail: true passes --warn-fail to validate, so WARN fails the job.
validate exitAction, defaultAction, warn-as-fail: true
0 PASSpasspass
1 FAILfailfail
2 WARNpassfail
3 tool errorfailfail

There is no per-code allow-list or severity threshold: FAIL always fails, a tool error always fails, and WARN is the one outcome you choose for.

One WARN worth knowing about in CI: a migration statement that runs past --statement-timeout (1 minute by default) is cancelled and the verdict is floored to WARN — never PASS, because it was not shown to be safe. A deliberately long backfill needs a higher ceiling; pass it through the Action’s args input, for example args: --statement-timeout 5m.

rowshape annotate turns a JSON verdict into GitHub’s two review surfaces: one workflow command per finding that has a file and line, which GitHub shows inline on the diff, and a Markdown summary appended to $GITHUB_STEP_SUMMARY. The Action runs it for you, even when the verdict is FAIL. By hand:

Terminal window
rowshape validate rowshape.yaml -m migrations/ --ephemeral "$SCRATCH_PG" --json > verdict.json
rowshape annotate verdict.json

For the failing migration in the quickstart, the inline annotation it prints starts like this (truncated here):

::error file=migrations/001_email_not_null.sql,line=1,title=RS-APPLY-001::Migration did not apply: column "…" of relation "…" contains null values [relation users, column email]%0A%0A…

and the check summary is a table of every finding — including those with no file and line, such as RS-DATA-001, which cannot be placed inline:

## rowshape: FAIL
Fixture `fixture@2026-10-28` · 1ms
| Code | Severity | Estimate | Remediation |
| --- | --- | --- | --- |
| RS-APPLY-001 | error | — | Read the SQLSTATE and message in the finding's evidence: … |
| RS-DATA-001 | warn | — | Backfill or delete the NULL rows first, or add a DEFAULT; then SET NOT NULL. … |
.github/workflows/migrations.yml
name: migrations
on:
pull_request:
paths:
- "migrations/**"
- "rowshape.yaml"
permissions:
contents: read
jobs:
rowshape:
runs-on: ubuntu-latest
services:
postgres:
# Match the major version production runs.
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready --health-interval 10s
--health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@v4
- id: rowshape
uses: rowshape/rowshape@v1
with:
fixture: rowshape.yaml
migrations: migrations
# A disposable database on the service container; no production credential.
ephemeral: postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable
# Block the merge on WARN as well as FAIL.
warn-as-fail: true
- if: always()
run: echo "verdict=${{ steps.rowshape.outputs.verdict }} exit=${{ steps.rowshape.outputs.exit-code }}"

The Action’s outputs are verdict (PASS, WARN or FAIL; empty on a tool error), exit-code (the raw validate code) and verdict-json (the path of the captured JSON verdict, set only when the file was written). Every input is listed in the GitHub Action reference.

Anything that can run a Postgres container and a binary can gate on the exit code. The whole job is one command:

Terminal window
rowshape validate rowshape.yaml -m migrations/ --ephemeral "$SCRATCH_PG" --warn-fail

Drop --warn-fail to let WARN through — but then remember that 2 is still non-zero, so your script has to allow it explicitly. The Docker image (ghcr.io/rowshape/rowshape) works where installing a binary is awkward.