The migration command exited successfully on your laptop. CI accepted the image. The platform accepted the job. None of those facts establishes that the database your new application will use has the schema it expects.
A useful release gate joins those facts deliberately: choose one release image, execute one finite migration command, observe its terminal result, verify the resulting schema independently, and permit the existing deployment job only after all checks succeed. The uncomfortable cases matter most: a lost start response, a migration that committed before CI disconnected, or a successful process that changed the wrong database.
This tutorial includes a complete small example with a synthetic PostgreSQL migration, a fail-closed Runs API client and downloadable test results. It extends the release pattern in Containers versus Runs; it does not introduce another web-server startup hook.
Download the migration, gate, Dockerfile, fixtures and local results. Validation on 2 November 2026 used real isolated PostgreSQL 18.6 for database operations and synthetic API fixtures for Run outcomes. No hosted Run, staging deployment or production migration was performed.
Start with a bounded release contract
The example uses an additive migration: add a nullable published_at timestamptz column to a synthetic notes table. Existing application instances can continue using the previous columns while a new release starts. That makes it suitable for demonstrating a gate without pretending a schema downgrade can undo arbitrary data changes.
For Rapids Runs, confirm Preview access before planning the release. The current contract provides an explicit command argument array, a tag override within the parent's image repository, and a 60–3600-second timeout. Only one Run can be queued or running per parent container. Runs have no automatic retries. Keep the current Runs documentation beside the implementation rather than assuming a generic jobs API.
The gate is a prerequisite of your existing deploy job. It does not change traffic, publish images, deploy a revision or undo a migration. Configure CI so that failure, timeout, a skipped gate or an unknown outcome prevents deployment.
Pin the release image within the actual API
Build the migration code into the same candidate image as the application. Give the build a unique release tag, such as release-demo-20261102, and enforce that tag's immutability in your registry. Record the image manifest digest in the release record. Never use a moving latest tag for this workflow.
The Runs API accepts image_tag, not an arbitrary digest reference or a different repository. The example therefore uses an immutable-tag workflow. It passes the tag only, then verifies that the returned full image string matches the expected repository and tag. It also verifies the command and parent container.
A RELEASE file is baked into the image. The migration process compares that marker with the requested release before doing any DDL. This catches an accidentally reused build marker, but it is not cryptographic digest attestation. If your registry cannot enforce immutable tags, solve that prerequisite before treating this as a trustworthy image pin.
The supplied Dockerfile packages the migration fixture and uses a digest-pinned Python base. A real application keeps its normal web startup command; the finite Run overrides that command with the migration entry point.
Reproduce the local failure cases first
Unpack the archive outside your application checkout, use Python 3.12 or 3.13, and start Docker:
python -m venv .venv
. .venv/bin/activate
pip install -r requirements.txt
python -m unittest -v
python local_integration.py > my-local-results.json
The integration runner starts one disposable PostgreSQL container with random credentials and a random loopback port. It creates new fixture databases, loads the deliberately small bootstrap schema, executes real DDL and stops its own container afterward. It never uses an existing compose stack, application database or cloud API.
The database fixture verifies the successful migration, checksum ledger, duplicate-dispatch guard, both database locks and transaction rollback. Its ambiguous-dispatch case commits the migration and then simulates a lost response. That is intentional: a timeout can hide a successful database change, so automatic replay would be the wrong conclusion.
| Observed fixture | Gate outcome |
|---|---|
| Terminal success, integer exit code zero, expected schema | Verified |
| Nonzero exit or terminal timeout | Blocked |
| Success response with missing schema | Blocked |
| Lost dispatch response after migration commit | Blocked; no automatic replay |
| Repeated invocation for the same release | Blocked before another POST |
| Concurrent gate or migration lock held | Blocked |
Unit fixtures additionally reject malformed envelopes, mismatched images and commands, inconsistent terminal states, and missing or nonzero exit codes. They exercise the observation deadline independently from the platform's execution timeout.
Use an explicit finite command
After the local checks, prepare a disposable staging database and configure its private connection string on the parent container. The supplied bootstrap.sql is only for that synthetic database. It creates a new release_demo schema and deliberately fails if it already exists; it is not an application migration installer.
The Run's request body is small and explicit:
{
"command": ["python", "/app/migrate.py", "--release", "release-demo-20261102"],
"image_tag": "release-demo-20261102",
"timeout_seconds": 600
}
Arguments are an array, not a shell command string. No shell expansion is needed here. Omitting the command could start the image's web server and leave the Run alive until its timeout, which is the wrong release operation.
The parent supplies the staging database environment. CI receives the same target database connection and a token with the required Run read/write abilities through its secret mechanism. Keep credentials out of command arguments, image layers, diagnostic output and downloaded result files.
python gate.py --container "$CONTAINER_ID" --image "$RELEASE_IMAGE" --timeout 600 --max-wait 720
RELEASE_IMAGE is the full candidate reference, including its immutable release tag. The default API base is https://danubedata.ro/api/v1. The client uses TLS, refuses redirects, sends no automatic second POST, and imposes bounded network reads. Keep the normal CI failure behavior; do not append a success override or configure automatic retries around this job.
Record intent before the network request
The gate uses a dedicated direct PostgreSQL connection with autocommit. A session advisory lock serializes gates for the target database, while a primary key on the release journal prevents a second dispatch for an already recorded release. The journal row is committed before calling the API.
That ordering creates a conservative failure mode. If CI crashes before sending the request, the journal can still require reconciliation even though no Run started. The alternative, losing the record after a successful dispatch, would make an automated second invocation harder to distinguish from a new release.
Use a direct connection with session affinity. A transaction-mode pooler is unsuitable for this session lock. The migration itself takes a separate transaction advisory lock, checks a unique migration ID and checksum, and applies the DDL plus ledger entry in one transaction. A matching previously applied migration is verified without executing its DDL again.
PostgreSQL's locking documentation explains the different advisory-lock lifetimes. The supplied keys belong to this fixture; adapt the coordination scheme when integrating with an existing migration tool. Other writers must follow the same protocol for that coordination to be effective.
Verify the schema after terminal success
An HTTP 202 response means dispatch was acknowledged, not that the migration succeeded. The gate validates the response envelope, retains the Run ID and polls that exact Run. It accepts only a recognized terminal success with an integer exit code of zero and matching container, image and command.
Then it asks PostgreSQL independently for the migration checksum and column definition, and executes a representative query using the new column. A log line claiming success cannot satisfy this check. An API success with a missing column cannot satisfy it either.
The expected schema check is deliberately specific: nullable timestamp with time zone in the expected schema and table. Replace it with the application invariant your release needs. A check that merely asks whether any migration history row exists is too weak to demonstrate the required change.
Reconcile uncertainty without inventing a retry
If the start response is lost or malformed, the gate blocks. If a known Run cannot be observed or the observation deadline expires, it blocks. It does not cancel the Run, clear the journal, select a new release tag or replay the command.
Use the container's Runs list and the release record to identify the actual command, image, time window and Run ID. Inspect the terminal outcome and query the release journal and migration checksum ledger through a read-only connection. The database may already contain a committed change. A missing response, or an elapsed CI deadline, does not prove rollback.
If you cannot establish an unambiguous match, stop the deployment. A human must resolve whether work could still start, whether a migration lock remains held, and which database changes committed. The example intentionally has no force/retry switch. Creating another release tag merely to bypass the duplicate guard would defeat its purpose.
Keep recovery and destructive changes separate
This fixture relies on PostgreSQL transactional DDL. It does not cover nontransactional commands, external API side effects, long backfills or concurrent index creation. The ALTER TABLE reference is a reminder to inspect the locking and rewrite behavior of each real migration instead of assuming every additive change is equally cheap.
Use short lock and statement timeouts, test a production-shaped staging schema, and plan an expand/contract rollout when old and new application revisions coexist. Remove old columns in a later approved release. Cancelling a process cannot reverse a committed migration, and returning to an old image does not restore deleted data.
Rapids provides the finite execution step, and managed PostgreSQL provides the database on which the application invariant is checked. The remaining adoption gates are yours to verify: immutable tags, matching staging and production schemas, database permissions, a hosted Runs rehearsal and the deployment dependency itself. The local tests establish the example's failure behavior; they do not establish a completed production release.