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:
ptah schema validate --schema-file schema.sql --dialect postgresptah migrations plan \ --schema-file schema.sql \ --db-url "$PTAH_DATABASE_URL" \ --report json --check-destructiveptah 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.
GitHub Action
Section titled “GitHub Action”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:
ptah migrations plan --report textptah migrations plan --report json --check-destructiveptah migrations lint --format jsonThe 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.
What the Action posts
Section titled “What the Action posts”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>
```sqlGenerating 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:
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.
Inputs
Section titled “Inputs”| 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.
Lint the changeset, not all of history
Section titled “Lint the changeset, not all of history”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.
Database, permissions, and pinning
Section titled “Database, permissions, and pinning”- The action requires a database URL; use a disposable database in pull
request workflows. For SQLite smoke tests,
sqlite:///${{ runner.temp }}/ptah.dbis enough; for server databases, start a service container or provide a secret URL. checks: writeis needed for the destructive-change check run;issues: writeandpull-requests: writefor the sticky comment. On forked pull requests without write-scoped tokens, the action skips those writes and still completes the local validation path.- Pin
versionto a release tag instead oflatest, or pointbinary-pathat a Ptah built from source earlier in the workflow.
Any CI platform
Section titled “Any CI platform”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:
docker run --rm -v "$PWD:/src" -w /src \ ghcr.io/stokaro/ptah:0.5.0 \ migrations lint --dir ./migrations --dialect postgres --format jsonPin the image by digest in a pipeline that must be reproducible, the same way the deployment path pins a migration artifact.
GitLab CI
Section titled “GitLab CI”--format gitlab is a Code Quality artifact GitLab renders on the merge
request. The job is below.
Any platform with no findings format
Section titled “Any platform with no findings format”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.
One run cannot do both. Above the threshold the report goes to stderr and the
command exits 1, so a step that redirects stdout and relies on the exit code
publishes an empty artifact on exactly the run it exists to report. --fail-on none puts the report on stdout and exits 0; a second run at the real
threshold is what fails the step.
Azure DevOps
Section titled “Azure DevOps”- 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 findingsCircleCI
Section titled “CircleCI”- 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 postgresBitbucket Pipelines
Section titled “Bitbucket Pipelines”- 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.jsonThe 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.
Minimal shell checks
Section titled “Minimal shell checks”The same gate on any CI system, without a container:
ptah migrations validate --dir ./migrationsptah migrations lint --dir ./migrations --dialect postgresptah schema render --schema-file schema.sql --dialect postgres >/tmp/ptah-schema.sqlschema 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.
Upload lint findings to code scanning
Section titled “Upload lint findings to code scanning”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-lintThe upload step needs the security-events: write permission. Use
--fail-on none when code scanning owns the failure policy — above the
threshold the report goes to stderr and the command exits 1, so a plain
stdout redirect would capture an empty file. Prefer
--format github-actions when inline annotations are wanted without the
code-scanning permission model.
Report findings on a GitLab merge request
Section titled “Report findings on a GitLab merge request”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.jsonPtah’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 report owns the outcome, for the same reason as
the SARIF step above: above the threshold the findings go to stderr and the
command exits 1, so a plain stdout redirect would capture an empty file.
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.
Recommended pull-request checks
Section titled “Recommended pull-request checks”| 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.
Exit behavior
Section titled “Exit behavior”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.
Keep CI deterministic
Section titled “Keep CI deterministic”- Pin the Ptah version used by CI.
- Commit migration files and
ptah.sumtogether. - Keep database URLs in secrets.
- Run Atlas-compatible scripts through
ptah-compat, renamed or symlinked asatlaswhen preserving existing Atlas scripts. - Link CI failures to Troubleshooting so users have recovery steps.
Next steps
Section titled “Next steps”- Assert behavior rather than safety: Test migrations and schemas.
- Understand the gates the contour relies on: Integrity and safety and Lint and gate unsafe SQL.
- Add a drift gate for long-lived environments: Compare and drift.