# Deliver a schema change

Follow one schema change from a reviewed pull request to a verified database, and see which guide owns each stage.

Source: https://docs.ptah.run/v0.25.0/operate/deliver/

import { CardGrid, LinkCard, Tabs, TabItem } from '@astrojs/starlight/components';

[Run checks in CI](../../testing/ci/) ends where a pull request is approved.
This page starts there and ends at a database whose state has been observed
after the change. It owns the sequence, not the detail: each stage links to the
guide that owns it.

![A reviewed change is published as an OCI artifact whose digest is resolved once. The same digest is promoted between environments and applied to each database, and every apply is followed by an observation. The artifact is content-bound; a plan and an observation belong to one database.](../../../assets/delivery-lifecycle.svg)

## The sequence

| Stage | Consumes | Produces | Proves |
| --- | --- | --- | --- |
| Review | The migration files or the desired schema | An approved pull request | The change was read by a person, and lint and tests passed on those bytes |
| Publish | The reviewed directory | An OCI artifact and its manifest digest | The bytes that ship are the bytes that were reviewed |
| Promote | The digest | A tag in the target environment's repository | The same artifact reaches the next environment |
| Apply | The digest and a database URL | Applied revisions | The database executed those statements |
| Verify | The database | An observation | The database is in the state the change intended |

The stages after Review are content-bound except the last two. An artifact
proves what it contains; it cannot prove anything about a database it has not
been applied to.

## Publish the reviewed directory

Hash and verify before uploading, then publish. The manifest digest is the only
identifier worth carrying forward, and `--format reference` prints it as a
pinned reference and nothing else:

```bash
ptah migrations hash --dir ./migrations

MIGRATIONS=$(ptah migrations push \
  oci://ghcr.io/acme/app-migrations \
  --migrations-dir ./migrations \
  --dir-format ptah \
  --verify-sum \
  --tag stable \
  --format reference)
```

The reference names the manifest this push wrote. A push that fails prints
nothing, so `MIGRATIONS` is never set to an artifact that was not published.

`--verify-sum` makes a missing integrity file an error rather than a warning. A
mismatched integrity file fails before the upload either way.

[OCI registry artifacts](../oci-registry/) covers authentication, private
registries, and attaching lint and plan reports to the artifact.

## Resolve the tag once, then carry the digest

A tag moves. Use the digest for every step after publication, so a retag
between two commands cannot change what is applied halfway through. The
reference the publish step captured is pinned already, and it is the one the
rest of this page applies:

```bash
ptah migrations status \
  --db-url "$DATABASE_URL" \
  --migrations-dir "$MIGRATIONS" \
  --json
```

A deployment that starts from a tag published elsewhere resolves it once, at
the start, and carries the digest from there:

```bash
MIGRATIONS=$(ptah oci resolve oci://ghcr.io/acme/app-migrations:stable)
```

`ptah oci tag` moves an alias without rebuilding the artifact, and `ptah oci
copy` preserves the manifest digest across repositories. Attached reports stay
at the source unless the copy is recursive, so a destination that must carry
its lint or plan evidence needs `--recursive`.

Promotion moves an artifact. It does not move a plan or an observation: both of
those are specific to the database they were made against, so staging's clean
apply is not evidence about production.

## Promote between environments

`ptah oci tag` moves an alias inside one repository. `ptah oci copy` moves the
artifact to another repository, which is what a separate per-environment
registry needs, and it preserves the manifest digest: the artifact staging
approved is the artifact production applies, byte for byte.

```bash
ptah oci copy \
  oci://ghcr.io/acme/app-migrations@sha256:0123... \
  oci://registry.internal/prod/app-migrations \
  --recursive
```

`--recursive` carries the artifact's referrers with it. Without it the copy
takes the artifact alone and the lint and plan reports attached to it stay at
the source, so a production registry that must hold its own evidence needs the
flag. `ptah oci verify` then checks the destination against a verification
policy before anything consumes it.

What does not travel:

- **An approval.** It was given for a plan against one database.
- **An observation.** `migrations status` and `schema drift` answer for the
  database they read, so staging's clean result is not evidence about
  production.
- **A plan.** It binds the artifact to a target's observed state, and the next
  target has its own.

So each environment repeats apply and verify against its own database. What
promotion saves is the review, not the run.

## Apply

```bash
ptah migrations up \
  --db-url "$DATABASE_URL" \
  --migrations-dir "$MIGRATIONS" \
  --verify-sum
```

`ptah migrations down` accepts the same pinned source. Moving a tag backwards
is not a rollback: the database has already executed the statements, so
reversing them is [a rollback](../../versioned/rollback/) or a forward repair,
decided by what the failed change did.

Ptah publishes a deployment record to the registry after a successful apply.
That upload is best-effort, and its failure does not mean the migration failed.
Read the exit code of the apply, not the presence of the record, and use
`--skip-report` where the deploy credential has no push rights.

## Verify

An apply that exits `0` reports that the statements ran. Whether the database
now matches the schema the change intended is a separate question, and drift
detection is what answers it:

```bash
ptah migrations status --db-url "$DATABASE_URL" --migrations-dir "$MIGRATIONS"
ptah schema drift --db-url "$DATABASE_URL" --schema-file schema.sql
```

## Direct schema changes take a different path

The versioned path above ships a reviewed directory. A directly managed schema
ships a desired state instead: publish it with `ptah schema push`, and apply it
with a reviewed plan and, where configured, an approval. See
[Apply a direct schema change](../../direct/apply/).

A direct change produces no migration history. Do not describe it as one, and
do not expect `ptah migrations status` to report on it.

## Where the apply runs

<Tabs>
<TabItem label="A CI job">

The commands above, in a job that holds a database credential and the digest.
This is the path that works everywhere, including a platform with no Ptah
integration.

</TabItem>
<TabItem label="Kubernetes">

The [Ptah Operator](../kubernetes-operator/) works from an OCI artifact, with
plans and approvals as cluster resources. `PtahSchema` reconciles a desired
schema continuously, and `PtahMigration` runs a versioned migration sequence, so
on either path it can take the place of the apply step above.

</TabItem>
<TabItem label="Argo CD or Flux">

A GitOps controller syncs Kubernetes objects; it does not run migrations. Two
shapes work, and which one fits depends on the workflow rather than on the
controller.

**A desired schema** is the shape GitOps already fits. Commit the `PtahSchema`
resource, let Argo CD or Flux sync it, and the operator reconciles from there.
Convergence is the operator's job and the controller only has to deliver the
resource.

**A versioned directory** can be a `PtahMigration` resource, which the operator
runs once it is synced. Without the operator it needs a Job that runs
`ptah migrations up` against
the pinned digest, ordered before the workload that depends on the new schema.
Argo CD runs it as a `PreSync` hook; Flux orders it with `dependsOn` between
Kustomizations. In both cases the Job image is `ghcr.io/stokaro/ptah` pinned by
digest, and the migration reference is a digest for the reason above: a
controller re-syncs, and a tag that moved between two syncs would apply
something nobody reviewed.

Neither controller reports whether the database converged. The Job's exit code
does, and the verify step is still yours to run.

</TabItem>
</Tabs>

## Next steps

<CardGrid>
  <LinkCard
    title="OCI registry artifacts"
    href="../oci-registry/"
    description="Authenticate, publish, pin, verify, and attach reports to an artifact."
  />
  <LinkCard
    title="Kubernetes operator"
    href="../kubernetes-operator/"
    description="Reconcile a schema or run migrations in a cluster, and what that path does not cover."
  />
  <LinkCard
    title="Rollback"
    href="../../versioned/rollback/"
    description="Recover from a change that reached the database and should not have."
  />
  <LinkCard
    title="Run checks in CI"
    href="../../testing/ci/"
    description="The stage before this one: what a pull request should prove."
  />
</CardGrid>
