Skip to content
PtahDocs
v0.8.1
Page type: how-to

CI

Gate pull requests with the Ptah GitHub Action or shell checks, and read exit codes correctly.

Run Ptah in CI to catch migration drift, destructive changes, hash mismatches, and unsupported capabilities before merge. The source-neutral path invokes the CLI directly and names the desired-schema source explicitly:

Terminal window
ptah schema validate --schema-file schema.sql --dialect postgres
ptah migrations plan \
--schema-file schema.sql \
--db-url "$PTAH_DATABASE_URL" \
--report json --check-destructive
ptah schema drift \
--schema-file schema.sql \
--db-url "$PTAH_DATABASE_URL"

Replace --schema-file schema.sql with the exact selector for another source. Use a disposable database for the plan and a controlled long-lived environment for the drift gate.

Ptah ships a Marketplace GitHub Action as stokaro/ptah-action@v1. On a pull request it generates a migration plan, evaluates the safety verdict, optionally lints the migration directory, updates one sticky pull request comment, and writes a Ptah destructive-change verdict check run from the machine-readable safety report.

The action takes the same desired-schema sources the CLI does. dir selects Go annotation roots, schema-file selects SQL, YAML, HCL, DBML or oci:// sources, and schema-cmd with schema-format selects an external loader. Each one forwards to the flag of the same meaning, and dir and schema-file take one value per line and compose.

A run that selects no Go source installs no Go toolchain, even with the default setup-go: true.

name: Ptah
on:
pull_request:
permissions:
checks: write
contents: read
issues: write
pull-requests: write
jobs:
ptah:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: stokaro/ptah-action@v1
with:
dir: ./internal/models
db-url: ${{ secrets.PTAH_DATABASE_URL }}
dialect: postgres
migration-dir: ./migrations
lint: "true"
comment: "true"

Behind the scenes the action runs:

Terminal window
ptah migrations plan --report text
ptah migrations plan --report json --check-destructive
ptah migrations lint --format json

The text plan keeps the SQL in the pull request comment for reviewers; the separate JSON safety run drives the destructive-change gate. With allow-destructive: "false" (the default), a destructive plan fails the job after the comment is posted; lint failures also fail the job after the comment is posted.

The comment is assembled from the three commands above. On a pull request that adds one table it reads:

## Ptah migration plan
Safety: safe.
Plan command: exit 0.
Safety command: exit 0.
Lint command: exit 0.
<details><summary>Migration SQL and safety text</summary>
```sql
Generating migration from ./models to database sqlite://app.db
=== GENERATE MIGRATION SQL ===
Safety classification:
# severity subject reason
1 safe *ast.CreateTableNode does not remove data or tighten constraints
=== MIGRATION SQL ===
-- Migration generated from schema differences
-- Generated on: now
-- Source: ./models
-- Target: sqlite://app.db
CREATE TABLE "users" (
"id" INTEGER PRIMARY KEY,
"email" TEXT NOT NULL
);
Generated 1 migration statements.
⚠️ Review the SQL carefully before executing!
```
</details>
<details><summary>Safety JSON</summary>
```json
{
"highest": "safe",
"destructive": false,
"assessments": [
{
"index": 1,
"node_type": "*ast.CreateTableNode",
"statement": "CREATE TABLE \"users\" (\n \"id\" INTEGER PRIMARY KEY,\n \"email\" TEXT NOT NULL\n)",
"severity": "safe",
"reason": "does not remove data or tighten constraints"
}
]
}
```
</details>
<details><summary>Lint JSON (0 finding(s))</summary>
```json
{
"failed": false,
"failure_threshold": "error",
"dialect": "sqlite",
"dir": "./migrations",
"findings": []
}
```
</details>

The summary lines are the review surface; the collapsed sections carry the evidence. A reviewer who only reads the four lines still learns the safety verdict and whether any command failed.

The check run beside it, Ptah destructive-change verdict, carries the same facts in the form a branch protection rule can require:

Ptah migration safety: safe
Safety verdict: safe.
Destructive changes: no.
Plan command exit code: 0.
Safety command exit code: 0.
Lint command exit code: 0.
Lint findings: 0.

Its conclusion is failure when any of the three commands exited non-zero, and when the plan is destructive and allow-destructive is not set.

To reproduce the comment body without a pull request, run the three commands into files and call the Action’s own assembler, which is exported for this:

Terminal window
node -e '
const { buildComment } = require("./.github/actions/ptah/comment.js");
console.log(buildComment());
'

with PTAH_PLAN_PATH, PTAH_SAFETY_PATH, PTAH_LINT_PATH, their _ERROR_ counterparts, and the three _EXIT_CODE variables pointing at that run.

Input Default Description
version latest Ptah release tag to download.
binary-path empty Existing Ptah binary path. Skips release download.
setup-go true Set up the Go toolchain before running Ptah.
go-version empty Go version passed to actions/setup-go. Empty reads the toolchain from go-version-file instead.
go-version-file go.mod Go module file the toolchain is read from, relative to the calling repository.
dir empty Root directory scanned for Go schema entities, repeatable one per line. Empty selects no Go source unless nothing else is selected, in which case it falls back to the current directory.
db-url required Target database URL used to read the current schema.
dialect empty Dialect passed to ptah migrations lint.
migration-dir migrations Migration directory passed to lint.
schemas empty Comma-separated database schemas to inspect.
comment true Whether to write a sticky PR comment.
lint true Whether to run ptah migrations lint.
lint-fail-on error Lint failure threshold: error, any, or none.
lint-git-base empty Base Git ref for lint, passed through as --git-base: only the migrations the pull request adds are linted. Needs fetch-depth: 0. Empty lints the whole directory.
lint-latest empty Lint only the newest N migrations, passed through as --latest. Ignored when lint-git-base is set.
allow-destructive false Allows destructive plans after review.
output-dir temporary Directory for generated reports.

The outputs plan-path, safety-path, lint-path, their captured stderr paths, and the destructive verdict (true, false, or unknown) let later workflow steps consume the generated reports.

The action lints the whole migration directory unless told otherwise. On a repository with history that reports migrations which shipped long ago and cannot be edited, and with the default lint-fail-on: error the gate is then permanently red. An immutable history is not a changeset.

A pull request has a changeset, so compare against its base; a push has none, so lint the newest migration. A shallow checkout does not fetch the base ref, and the lint then fails with bad revision <base>...HEAD rather than linting nothing, which is why the checkout asks for history:

- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: stokaro/ptah-action@v1
with:
db-url: ${{ secrets.DATABASE_URL }}
lint-git-base: ${{ github.event_name == 'pull_request' && github.base_ref || '' }}
lint-latest: ${{ github.event_name == 'pull_request' && '' || '1' }}

lint-git-base wins when both are set.

  • The action requires a database URL; use a disposable database in pull request workflows. For SQLite smoke tests, sqlite:///${{ runner.temp }}/ptah.db is enough; for server databases, start a service container or provide a secret URL.
  • checks: write is needed for the destructive-change check run; issues: write and pull-requests: write for the sticky comment. On forked pull requests without write-scoped tokens, the action skips those writes and still completes the local validation path.
  • Pin version to a release tag instead of latest, or point binary-path at a Ptah built from source earlier in the workflow.

The Action is GitHub-shaped, and the parts of it that are not are the two machine-readable outputs it renders. Both are available to any platform:

Output What it answers
ptah migrations plan --report json --check-destructive Whether the plan is destructive, as a document rather than an exit code
ptah migrations lint --format json Every finding, with rule, severity, file and line

--format sarif, --format github-actions and --format gitlab are the same findings in a form a particular platform ingests. A platform with no format of its own reads the JSON and renders it however it reports.

Ptah ships a container image, so a platform with no marketplace entry needs no installation step. Its entry point is ptah, so the image takes the arguments directly:

Terminal window
docker run --rm -v "$PWD:/src" -w /src \
ghcr.io/stokaro/ptah:0.5.0 \
migrations lint --dir ./migrations --dialect postgres --format json

Pin the image by digest in a pipeline that must be reproducible, the same way the deployment path pins a migration artifact.

--format gitlab is a Code Quality artifact GitLab renders on the merge request. The job is below.

Azure DevOps, CircleCI and Bitbucket Pipelines have no findings format of their own here, so the report is published as a plain build artifact. Each takes two runs: one that writes the file and one that decides the outcome.

The report is on stdout whatever the outcome, so one run can write the file and fail the step. The two runs below keep publishing apart from the verdict: the first, with --fail-on none, writes the report and exits 0, and the second, at the real threshold, is the one that fails the job. On a platform that skips later steps once one fails, that order is what keeps the publishing step running.

- script: |
ptah migrations lint --dir ./migrations --dialect postgres \
--fail-on none --format sarif > ptah-lint.sarif
displayName: Write the lint report
- task: PublishBuildArtifacts@1
inputs:
pathToPublish: ptah-lint.sarif
artifactName: ptah-lint
- script: ptah migrations lint --dir ./migrations --dialect postgres
displayName: Fail on lint findings
- run:
name: Write the lint report
command: >
ptah migrations lint --dir ./migrations --dialect postgres
--fail-on none --format json > ptah-lint.json
- store_artifacts:
path: ptah-lint.json
- run:
name: Fail on lint findings
command: ptah migrations lint --dir ./migrations --dialect postgres
- step:
name: Lint migrations
script:
- >
ptah migrations lint --dir ./migrations --dialect postgres
--fail-on none --format json > ptah-lint.json
- ptah migrations lint --dir ./migrations --dialect postgres
artifacts:
- ptah-lint.json

The report is redirected rather than piped. A pipeline’s exit status is its last stage’s, so ptah ... | tee ptah-lint.json reports tee and the step passes with findings outstanding.

What none of these get is the sticky comment and the check run. Those are rendered by comment.js and check-run.js in the Action and have no equivalent elsewhere yet.

The same gate on any CI system, without a container:

Terminal window
ptah migrations validate --dir ./migrations
ptah migrations lint --dir ./migrations --dialect postgres
ptah schema render --schema-file schema.sql --dialect postgres >/tmp/ptah-schema.sql

schema render writes executable SQL to stdout and diagnostics to stderr. A CI job can apply /tmp/ptah-schema.sql unchanged to a disposable database to test the public command path, including cyclic foreign key ordering.

Use a disposable database for migrations plan, migrations generate, and migrations up in pull requests.

ptah migrations lint --format sarif emits a SARIF 2.1.0 document that GitHub code scanning ingests, turning findings into pull-request annotations with stable rule identifiers:

- name: Lint migrations
run: >
ptah migrations lint --dir ./migrations --dialect postgres
--fail-on none --format sarif > ptah-lint.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: ptah-lint.sarif
category: ptah-lint

The upload step needs the security-events: write permission. Use --fail-on none when code scanning owns the failure policy: the report is on stdout either way, and exit 0 lets the upload step run when there are findings. Prefer --format github-actions when inline annotations are wanted without the code-scanning permission model.

ptah migrations lint --format gitlab emits a GitLab Code Quality report. GitLab reads it as a codequality artifact and annotates the changed lines of a merge request:

ptah-lint:
image: alpine:3
script:
- ptah migrations lint --dir ./migrations --dialect postgres
--fail-on none --format gitlab > gl-code-quality-report.json
artifacts:
reports:
codequality: gl-code-quality-report.json

Ptah’s three severities map onto GitLab’s scale as error to major, warning to minor, and info to info. A file-level finding carries no line, and GitLab requires one, so those anchor to the first line of the file they name.

Use --fail-on none when the merge request widget owns the outcome. Without it the same run also fails the job on findings at the threshold, and the report still reaches the merge request, because GitLab uploads an artifacts:reports file whatever the job’s result.

A run that fails before it can lint is reported as one blocker entry rather than as an empty report, because an empty Code Quality artifact is indistinguishable from a clean one on the merge request.

Check Why it exists
migrations validate Fails when committed migration files and ptah.sum disagree.
migrations lint Catches risky SQL before it reaches a database.
schema render Proves the desired schema source parses and produces executable, capability-valid SQL.
migrations plan against a disposable DB Shows the SQL Ptah would apply.
migrations up --verify-sum --dry-run Exercises the apply path without changing the shared target.
schema drift Fails when a long-lived environment diverged from the desired schema.
schema validate --dialect <each target> Fails on a structural problem in the desired schema, with no database at all.
schema validate --no-skipped Also fails when rendering for a target would drop a declared object or property.
schema fmt --check Fails when an HCL schema file is not canonically formatted.
schema security --fail-on any Fails on a privilege, owner or role finding. Without --fail-on any the check reports and never fails, because no rule is error-severity.

Validate and format schema files and Report schema security findings cover the last three.

For live checks, prefer throwaway databases or service containers. Do not point a pull-request job at a production database.

See Exit codes before using Ptah as a gate. For native Ptah commands, 0 means success, 1 is reserved for command-specific negative check results such as drift, lint findings, pending migrations with --exit-code, or migration hash drift, and 2 means a usage, parse, connection, unsupported-dialect, or other command failure. Atlas-compatible surfaces use 1 for both negative results and command failures to match Atlas CE. A recovered internal panic remains exit 2 on either surface, so scripts should interpret the code according to the selected CLI surface.

  • Pin the Ptah version used by CI.
  • Commit migration files and ptah.sum together.
  • Keep database URLs in secrets.
  • Run Atlas-compatible scripts through ptah-compat, renamed or symlinked as atlas when preserving existing Atlas scripts.
  • Link CI failures to Troubleshooting so users have recovery steps.