# OCI registry artifacts

Publish, pin, pull, and consume Ptah migrations and desired schemas through an OCI registry.

Source: https://docs.ptah.run/v0.8.1/operate/oci-registry/

Ptah can store migration directories and desired schemas as OCI 1.1 artifacts
in GHCR, ECR, GAR, Harbor, Docker Hub, a local `registry:2`, or another
OCI-compliant registry. Authentication comes from the Docker credential store;
there is no Ptah account or Ptah-specific registry token.

This is a native Ptah workflow. It does not implement Atlas Cloud or the
`atlas://` scheme.

## Choose a reference

```text
oci://registry.example/team/repository
oci://registry.example/team/repository:tag
oci://registry.example/team/repository@sha256:<64-lowercase-hex-characters>
```

An unqualified reference resolves to `:latest`. Tags are movable pointers. A
manifest digest is immutable and is the appropriate pin for a reproducible
deployment:

```text
oci://ghcr.io/acme/app-migrations:stable
oci://ghcr.io/acme/app-migrations@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
```

Every push writes `latest`, a collision-resistant generated UTC version tag in
`vYYYYMMDDhhmmss-<random-base32>` form, the positional reference tag when
present, and each repeatable `--tag`. `--version` overrides the generated
version tag.

:::caution[Tags are not immutable]
Ptah rejects a generated or explicit version tag that already resolves to
different content, and generated tags include cryptographic randomness.
This is a client-side preflight: concurrent writers reusing an explicit
`--version` still require registry-side immutable-tag policy. Positional,
`--tag`, and `latest` aliases remain movable. Only the returned
`@sha256:...` digest is mechanically immutable.
:::

If a later tag update fails after earlier tags moved, Ptah reports the manifest
digest, completed tags, and failed tag.

Ptah also rejects references that contain both a tag and digest, embedded
credentials, query strings, fragments, escaped path separators, surrounding
whitespace, or uppercase repository components.

## Authenticate securely

Log in with the registry's normal Docker flow:

```bash
docker login ghcr.io
```

Ptah honors `DOCKER_CONFIG`, Docker's default `config.json`, `credsStore`, and
per-registry `credHelpers`. Do not include credentials in the `oci://`
reference.

Ptah also keeps a store of its own, for a machine with no Docker installed:

```bash
ptah oci login ghcr.io
ptah oci logout ghcr.io
```

The credential is checked against the registry before anything is written, so a
typo fails there rather than at the next push. The password is never taken from
the command line — it is read from the terminal, or from standard input when
one is piped in. A platform credential helper is used where one exists;
otherwise the credential goes to a Docker-format file under `~/.ptah`, and the
command says which of the two happened.

`ptah oci logout` removes what Ptah stored and nothing else. A credential
`docker login` placed is left alone, because taking it away would log you out
of something you did not ask about.

HTTPS is the default. `--plain-http` disables transport encryption and is only
for an explicitly trusted local registry used for development or tests. Do not
use it with GHCR, ECR, GAR, Harbor, Docker Hub, or a production registry.
Every command that resolves an `oci://` source registers the flag, including
`ptah oci referrers`, `ptah schema inspect`, and the rest of push, pull, and the
direct consumers.

Registry operations and Docker credential-helper lookups have a two-minute
default deadline, with shorter dial, TLS-handshake, and response-header
deadlines.

### Identity, integrity, and authenticity

An OCI digest identifies exact bytes, not the publisher. `ptah.sum` and
`atlas.sum` detect changes inside a migration directory, but they are not
signatures. Ptah does not currently verify artifact signatures.

Use registry access controls, trusted writers, digest allowlists, and a
registry-native signature policy when publisher authenticity is required.
Deployment reports are deliberately redacted. `plan.json` contains schema
fingerprints, dialect and capability metadata, object names, risk assessments,
and generated SQL statements, so protect it like schema and migration content.

## Publish migrations

Hash and verify the directory before publishing:

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

ptah migrations push \
  oci://ghcr.io/acme/app-migrations \
  --migrations-dir ./migrations \
  --dir-format ptah \
  --verify-sum \
  --tag stable
```

The migration artifact contains only the inputs Ptah captures for a migration
run:

- SQL files;
- `ptah.sum` or `atlas.sum`;
- `.ptah-lint.yaml`.

Unrelated files are excluded. `--dir-format` accepts `auto`, `ptah`, or `atlas`.
A mismatched integrity file always fails before the registry upload;
`--verify-sum` additionally makes a missing integrity file an error.

The command prints the manifest digest. Promote that exact artifact by digest:

```bash
MIGRATIONS=oci://ghcr.io/acme/app-migrations@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef

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

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

`ptah migrations down` accepts the same direct source:

```bash
ptah migrations down \
  --db-url "$DATABASE_URL" \
  --migrations-dir "$MIGRATIONS" \
  --target 5 \
  --confirm
```

`up`, `status`, and `down` pull into an immutable in-memory filesystem and use
the ordinary Ptah migration engine. An explicit `--dir-format` must match the
artifact metadata.

`--verify-sum` is available on `up`, `down` and `status`. It verifies the pulled
artifact before opening the database and, unlike the always-on gate, refuses an
artifact that carries no integrity file at all. Read
[Identity, integrity, and authenticity](#identity-integrity-and-authenticity)
for what that verification establishes over a movable tag, which is less than
the flag's name suggests: the sum travels inside the artifact, so it proves the
pulled files are internally consistent, not that they are the reviewed ones.
Pin a digest to fix which bytes a later pull gets; that section also explains
why pinning one is not by itself a statement about who published them.

`ptah migrations lint --dir oci://...` also reads an OCI migration artifact
directly. See [Attach lint and plan reports](#attach-lint-and-plan-reports) for
the optional referrer output.

`ptah migrations validate --dir oci://...` answers the integrity question on its
own, without a database and without executing anything:

```bash
ptah migrations validate --dir "$MIGRATIONS"
```

It exits 0 when the artifact matches the sum it carries, 1 when a migration was
added, removed or edited out of band, and 2 when the artifact carries no sum at
all — the same contract it has for a local directory.

When the reference is a tag, a successful run prints on standard error the same
movable-tag qualifier `up`, `down` and `status` print, naming the digest the tag
resolved to. A digest-pinned reference prints nothing extra: there is no movable
pointer left to warn about.

It does not replace `--verify-sum` on the consuming verbs: a separate call
resolves the tag in its own process, so a movable tag can select different bytes
before `up` or `down` resolves it again.

## Reconstruct a migration directory

```bash
ptah migrations pull \
  "$MIGRATIONS" \
  --out ./pulled-migrations
```

The destination path must be absent. Ptah stages the captured files and
renames them into place so a failed pull does not leave a partial directory.
Any existing file, symlink, or directory, including an empty directory, is
rejected without being overwritten.

:::caution[Fixed: this did not hold on Windows]
Earlier releases installed the staged directory with a move that asks Windows
to replace whatever it finds. An entry appearing at the destination between the
absence check and the install was therefore **overwritten there, and its
contents lost** — measured on `windows-latest`, a file written at the
destination mid-pull was gone and the pulled directory stood in its place. Unix
was unaffected, where the same move refuses an occupied destination.

The install now refuses on every platform ([#1547](https://github.com/stokaro/ptah/issues/1547)).
:::

## Understand deployment reporting

After an OCI-backed `migrations up` applies pending migrations successfully,
Ptah attempts to attach a `deployment.json` referrer to the exact migration
manifest digest that was pulled.

The report records:

- a generated deployment ID;
- the migration artifact digest;
- the database dialect;
- before and after versions;
- versions actually added to committed revision state;
- UTC start and finish timestamps;
- a `succeeded` outcome.

The report schema has no fields for database URLs, hostnames, environment
values, local paths, SQL text, or credentials.

No report is attempted for a local migration directory, a dry run, a no-op run,
a failed migration, or a failed final status check. Report attachment is
best-effort: a registry failure prints a warning but does not turn a successful
migration into a failed command.

Use `--skip-report` when the registry is read-only or another system owns the
deployment record:

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

## Attach lint and plan reports

Lint directly from an OCI migration artifact:

```bash
ptah migrations lint \
  --dir "$MIGRATIONS" \
  --dialect postgres \
  --format json
```

Add `--attach` to publish the canonical `lint.json` report as a referrer of the
exact migration manifest digest:

```bash
ptah migrations lint \
  --dir "$MIGRATIONS" \
  --dialect postgres \
  --format json \
  --attach
```

`--attach` requires an OCI `--dir`. `--git-base` is not supported with an OCI
lint source because the artifact has no local Git history. Ptah publishes the
report before applying the normal lint failure threshold, so findings can be
stored even when lint then returns its negative-result code.

Attach a canonical `plan.json` report to an OCI desired-schema artifact:

```bash
SCHEMA=oci://ghcr.io/acme/app-schema@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef

ptah migrations plan \
  --schema-file "$SCHEMA" \
  --db-url "$DATABASE_URL" \
  --report json \
  --attach
```

Plan attachment requires exactly one OCI `--schema-file`. Combining that source
with a local schema file, Go root, or external schema command is rejected
because Ptah cannot choose an unambiguous subject. Planning without `--attach`
can still include OCI schema artifacts in a composite desired schema.

The plan records the desired artifact digest, a SHA-256 fingerprint of the
complete live schema used as current state, the dialect, selected schemas,
effective capabilities, and statement assessments.

Lint and plan attachments are not best-effort. If publication fails, the
command fails.

### Referrer discovery guarantees

With a native OCI Referrers API, the registry provides the concurrent index.
Without it, ORAS writes the standard referrers tag-schema index and Ptah also
writes a unique content-derived tag for every attachment. `ptah oci referrers`
merges and validates both sources, so concurrent Ptah processes do not lose
attachments through one shared fallback tag.

The content-derived tags are a Ptah extension. A different client that reads
only the standard tag-schema index can miss an entry after concurrent
cross-process publication. Use a registry with the native Referrers API for
complete cross-client audit discovery.

## List referrer metadata

List every direct referrer attached to an artifact:

```bash
ptah oci referrers "$MIGRATIONS"
```

Filter by Ptah report type and select JSON output when automation needs stable
fields:

```bash
ptah oci referrers "$MIGRATIONS" --type lint --format json
ptah oci referrers "$SCHEMA" --type plan --format json
```

`--type` accepts `all`, `lint`, `plan`, or `deployment`, and `--format` accepts
`text` or `json`. Defaults are `all` and `text`. The result contains direct
referrer descriptor metadata. Text output contains digest, artifact type, media
type, and size; JSON output also contains annotations when present.
`ptah oci fetch` downloads the report payload itself — see
[Native commands](../../reference/native-commands/). It writes the published
bytes and does not interpret them.

A registry that gained the referrers index after Ptah published through its
durable tag holds attachments no other OCI client can find, and nothing about
the artifact says so. `ptah oci reindex` repairs that by republishing the
manifest: the content is byte-identical, so the digest does not move, and a
registry serving the index builds the entry when it receives a manifest
carrying a subject. The pass ends by asking the registry again, because one
that accepted the manifest and built no entry looks exactly like one that did
until somebody checks — anything still missing is reported as unrepaired rather
than counted as fixed.

The subject follows the ordinary reference rules. An unqualified subject
resolves to `:latest`, a tag resolves to its current manifest, and a digest
selects an immutable subject. Use a digest to inspect attachments for the exact
artifact used by a deployment or analysis run. The command uses Docker
credentials and HTTPS by default; `--plain-http` is only for an explicitly
trusted local registry.

## Read a repository before you consume it

Four questions come up before an artifact is applied, and each has a verb.

**Which aliases exist?** `ptah oci tags` lists them. The aliases are what a
promotion moves and what a pin replaces, so this is the view that says which of
them a repository currently carries.

```bash
ptah oci tags oci://ghcr.io/acme/db
```

**Which artifact does this alias name today?** `ptah oci resolve` answers with
the pinned reference alone, so a step can capture it:

```bash
DIGEST=$(ptah oci resolve oci://ghcr.io/acme/db:latest)
ptah migrations up --from "$DIGEST"
```

Pinning is what makes the two commands describe the same artifact. A tag moved
between them would otherwise change what runs without changing the pipeline.

**What does the artifact declare?** `ptah oci inspect` reads the manifest and
stops there — the descriptor, the artifact type, the subject when the artifact
is itself a referrer, the annotations, and each layer's name, media type, size
and digest. Nothing is downloaded.

```bash
ptah oci inspect "$DIGEST"
```

It reports referrer discovery too, and the source column is the part worth
reading. Ptah writes both the standard referrers index and its own
content-derived durable tag; a referrer listed as `durable-tag` came back from
the second mechanism only, which means Ptah finds it and another OCI client may
not. `--no-referrers` skips that lookup.

**Will another client find what Ptah published?** `ptah oci capabilities` asks
the registry rather than inferring it:

```bash
ptah oci capabilities oci://ghcr.io/acme/db
```

The question is put with the client pinned to the referrers API, so it cannot
quietly fall back to the tag schema and report a success that came from
somewhere else. A refusal naming the API as unsupported is the registry saying
no; anything else is a failure to ask, and is reported as an error rather than
as a no.

## Publish a desired schema

Publish one or more SQL, YAML, HCL, or DBML sources:

```bash
ptah schema push \
  oci://ghcr.io/acme/app-schema \
  --schema-file ./schema.sql \
  --dialect postgres
```

Annotated Go models publish the same way, with `--root-dir ./models` in place
of `--schema-file`. Ptah merges the selected sources and renders one canonical
`schema.hcl` layer, plus a `managed-data.json` layer when the schema declares
reference data.

That second layer carries the declared rows themselves, under
`application/vnd.stokaro.ptah.managed-data.v1+json`. A `data` block names a file
in the working copy that declared it, and an artifact travels without that
working copy, so the rows travel instead. Each value keeps the YAML tag it
resolved to and the text that declared it: `007` and `"007"` are different
values in the same column, and resolving them to Go values loses the
difference. A column absent from a row was not declared, and a column written as
`null` was declared null.

`--checks ./release-checks.sql` publishes a third layer, under
`application/vnd.stokaro.ptah.checks.v1+sql`, carrying the `-- +ptah check`
release assertions that describe what the change was for. `ptah db verify
--checks oci://...` reads them back, so the assertions a reviewer approved and
the assertions that run are one set of bytes rather than two files that drifted.
Pin the reference by digest to make that binding hold: a tag can be moved, and
a digest names the bytes it named. See [Verify a release](../verify-a-release/).

A reader that does not know the managed-data media type refuses the artifact
instead of reading the schema layer and deploying a database without the rows
its author declared. The media type is what an artifact says its reader must
understand: a build too old for a layer refuses the whole artifact rather than
reading around it, which is the same rule a new layer added later will be read
under.

`ptah schema apply` and `ptah schema plan` resolve an artifact before they open
the target, so that refusal lands before a database connection exists. A reader
that verified after connecting would have opened the database with whatever the
credentials allow, for an artifact it then declined.

That canonical HCL includes API export metadata authored in YAML, HCL, or Go:
`api_name`, `openapi_name`, `graphql_name`, and `proto_name` on tables and
columns, plus column `api_type` and `api_expose`. Pulling the artifact and
rendering it again preserves those values and therefore the same OpenAPI,
GraphQL, and Protobuf identities. SQL and DBML inputs have no lossless spelling
for this metadata, so their contracts continue to derive names and types from
storage. A [composite source](../../schema/composite/) retains metadata from
each complete YAML, HCL, or Go-owned object and rejects conflicting duplicate
definitions.

Schema publication fails closed:

- managed reference data is rejected because it cannot be represented without
  loss;
- role passwords are rejected so credentials cannot be published in an
  artifact;
- any lossy HCL renderer diagnostic rejects publication;
- the generated HCL must parse and render back to identical canonical bytes.

Ptah never pushes a partial schema artifact.

### Decide which tags a push writes

A push with no tag flag moves `latest` onto the new digest and writes nothing
else. Four flags widen that:

| Flag | Writes | Movable |
| --- | --- | --- |
| `--version v1.0.0` | that tag, plus `latest` | no — write-once |
| `--generated-version` | `v<UTC timestamp>-<id>`, plus `latest` | no — write-once |
| `--latest` | `latest` | yes |
| `--tag stable` | that tag, repeatable, plus `latest` | yes |

The receipt names every tag the push wrote:

```text
Pushed schema as oci://ghcr.io/acme/app-schema:latest
Digest: sha256:20ec84e49b907163744a887dc2126491053bba1f2c82ddf6c73c006babc81512
Version: v1.0.0
Tags: [v1.0.0 latest]
```

A write-once tag is not reserved, it is pinned to a digest. Pushing the same
content under a version tag that already exists succeeds and changes nothing.
Pushing different content under it refuses, names both digests, and exits `2`:

```text
error: push oci://ghcr.io/acme/app-schema:latest: OCI write-once tag already exists: "v1.0.0" resolves to sha256:20ec84e4... instead of sha256:5b238472...
```

`Version:` is blank on a push that assigned no version tag, which is every push
without `--version` or `--generated-version`.

### Pull an artifact back to a file

Pulling validates the artifact and creates a new canonical HCL file:

```bash
ptah schema pull \
  oci://ghcr.io/acme/app-schema:stable \
  --out ./schema.hcl
```

The output file must not already exist. A second pull to the same path refuses
with `error: schema artifact output already exists: <path>` and exits `2`, so a
pull never overwrites a working copy. A reference the registry does not hold
refuses with `error: pull ...: fetch OCI manifest: ...: not found` and exits `2`
as well.

An artifact that declares reference rows is written as two files: the canonical
HCL at `--out`, and `managed-data.json` beside it. Both paths are printed. The
HCL carries the declaration and not the rows -- a `data` block names the row
file of the working copy that published the artifact, and that path exists
nowhere else -- so the layer beside the schema is the only account of the rows a
consumer has. Keep the pair together: `--schema-file` reads the rows from it,
whether you name the HCL or the directory holding it. Neither file is written
when the other cannot be, because a canonical HCL beside a missing row layer
reads exactly like an artifact that declares no rows.

### Exit codes

Both verbs exit `0` on success and `2` on every refusal: a usage error, an
authentication or transport failure, a lossy schema, a write-once tag collision,
an `--out` path that exists, or a reference that does not resolve. Neither uses
`1`. See [Exit codes](../../reference/exit-codes/).

:::caution
A source that declares no tables is published rather than refused. The push
exits `0` and writes an artifact whose canonical HCL is the generated-file
header and nothing else, so a build step that reads only the exit code can move
`latest` onto an empty schema. Check that the source parsed —
`ptah schema render` over it prints the tables — before the push step.
:::

## Consume a desired schema

Every native command that accepts `--schema-file` resolves `oci://` through it
and exposes `--plain-http` for a trusted local registry. This includes
`schema test`; the command-surface test keeps the flag and resolver paired as
new consumers are added.

The scheme belongs to that flag rather than to desired state in general.
`schema diff` takes its sources through `--from`/`--to`, does not resolve
`oci://`, and does not register `--plain-http`. Pull the artifact with
`ptah schema pull` and pass the file when using that command.

`schema inspect` is the one that materializes rather than parses: the artifact
is pulled to a canonical HCL file, that file is materialized on the
destructively reset `--dev-url` database, and the result is introspected — so
its output is normalized by a real database of the target dialect, exactly as
it is for a local schema file. The output is byte-identical to inspecting the
same artifact after `ptah schema pull`.

```bash
ptah schema inspect \
  --schema-file "$SCHEMA" \
  --dev-url "$DEV_DATABASE_URL"
```

The Atlas-compatible `ptah-compat schema inspect --url` deliberately does not
accept `oci://`, and refuses it with the community binary's own
`sql/sqlclient: unknown driver "oci"`. That surface's contract is to match the
pinned community binary, which has no `oci://` driver.

## Compare and check drift

Use an OCI schema through the repeatable `--schema-file` option:

```bash
SCHEMA=oci://ghcr.io/acme/app-schema@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef

ptah schema compare \
  --db-url "$DATABASE_URL" \
  --schema-file "$SCHEMA"

ptah schema drift \
  --db-url "$DATABASE_URL" \
  --schema-file "$SCHEMA" \
  --format json
```

OCI schema inputs can be combined with other repeatable `--schema-file` and
`--root-dir` inputs through Ptah's composite schema loader.

## Map Atlas concepts to OCI

| Atlas Registry | Ptah OCI |
| --- | --- |
| `atlas://app` | `oci://registry.example/team/app` |
| Unpinned source resolves to latest | Unqualified `oci://` resolves to `:latest` |
| Atlas login and `ATLAS_TOKEN` | `docker login`, `DOCKER_CONFIG`, and credential helpers |
| Registry version | Generated `vYYYYMMDDhhmmss-<random-base32>` tag or explicit `--version` |
| Movable tag | Reference tag or repeatable `--tag` |
| Immutable content pin | OCI manifest `@sha256:...` digest |
| Migration directory plus `atlas.sum` | Per-file OCI layers, including `atlas.sum` or `ptah.sum` |
| Desired schema | Canonical, lossless `schema.hcl` artifact |
| Deployment audit | Best-effort deployment-report referrer after native `migrations up` |
| Lint results and migration plans | `migrations lint --attach` and `migrations plan --attach`; list direct descriptor metadata with `ptah oci referrers` |
| Cloud UI, promotion, policy, and accounts | Not provided; use registry-native controls |

The native workflow does not make the Atlas-compatible `migrate push` or
`schema push` implemented commands. Those paths remain Atlas community-edition
boundary stubs in the `ptah-compat` binary, and the Atlas-compatible apply
command does not gain native OCI transport flags.

## Publish to GHCR from GitHub Actions

Grant the job `packages: write`. The Docker login action writes credentials that
Ptah reads from Docker's configuration:

```yaml
name: Publish migrations

on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-go@v6
        with:
          go-version: "1.26.x"

      - name: Log in to GHCR
        uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Install Ptah
        run: go install ptah.run/cmd/ptah@latest

      - name: Publish migration artifact
        shell: bash
        run: |
          ref="oci://ghcr.io/${GITHUB_REPOSITORY,,}-migrations"
          ptah migrations push "$ref" \
            --migrations-dir ./migrations \
            --verify-sum \
            --tag "$GITHUB_SHA"
```

Pin the Ptah install to a release or commit in production CI. Existing
organization packages may also need the repository's Actions workflow granted
write access in the package settings. GitHub documents the current token and
package permission model in [Publishing and installing a package with GitHub
Actions](https://docs.github.com/en/packages/managing-github-packages-using-github-actions-workflows/publishing-and-installing-a-package-with-github-actions).

## Next steps

- Verifying what you publish and apply:
  [Integrity and safety](../../versioned/integrity-and-safety/).
- Linting a published directory and gating destructive statements:
  [Lint and gate unsafe SQL](../../versioned/lint/).
- The complete verb and flag inventory for the OCI commands:
  [Native commands](../../reference/native-commands/).
- Which registry-backed features are supported where:
  [Capabilities](../../reference/capabilities/).
