Skip to content

feat(deployments): add a release phase: commands run once per deploy before cutover - #592

Open
Farahat612 wants to merge 1 commit into
oblien:mainfrom
Farahat612:feat/deploy-release-phase
Open

feat(deployments): add a release phase: commands run once per deploy before cutover#592
Farahat612 wants to merge 1 commit into
oblien:mainfrom
Farahat612:feat/deploy-release-phase

Conversation

@Farahat612

Copy link
Copy Markdown

An app project declares exactly one command (the start command), so
framework release steps had nowhere to run: php artisan migrate --force,
rails db:migrate, manage.py migrate never executed, and a stock Laravel
app's schema had to be bootstrapped by hand through the service terminal
(TODO.md "A generic release phase", adjacent to #231).

Projects (and openship.json) can now declare releaseCommands: string[].
They are frozen onto the deployment's config snapshot: a redeploy or
rollback replays the commands that release declared, not today's project
row: and run in the pipeline after a successful build, before any
cutover: before runDeployPipeline, before domain records, while the
previous version is still running and routed. Each command gets its own
log marker; a non-zero exit or a 10-minute per-command timeout fails the
deploy with the command's output, leaving the old version serving. An
absent field deploys byte-identically to before.

Execution per runtime: docker runs each command in a throwaway container
off the freshly built image: not an exec into the running (old-image)
deployment: with the deploy's env, scoped volume binds and project
network, but no published port and no restart policy, removed on every
path. Bare runs in the staged artifact dir through the same login-shell
wrap the build uses, with the start command's env. Compose, cloud and
static deploys log a clear warn-and-skip naming the commands rather than
silently dropping them.

Schema: nullable project.release_commands jsonb (no default, no
backfill), migration 0098 following the volumes pattern; parse/schema
support in openship.json with the published JSON Schema and docs updated
(Laravel/Rails/Django examples). Dashboard UI intentionally deferred.

Tests at every seam: openship-config parsing, snapshot carry in
build.service, phase ordering/failure wiring in the pipeline, and
runtime-level non-zero-exit/timeout/output-capture for docker and bare.
Each was verified to fail with its behavior reverted.

Implements the release-phase half of #231, per the design in TODO.md
("A generic release phase"). Proposed in a comment on that issue.

Design summary:

  • One insertion point in executeServerDeploy, right after deployConfig
    assembly: before runDeployPipeline, before any domain row. A failed
    migration aborts with the previous version still running and routed,
    nothing to unwind.
  • Docker runs each command in a throwaway container off the freshly built
    image (an exec into the running deployment would run new migrations inside
    the old image), with the deploy's env, scoped volume binds and project
    network, but no published port and no restart policy, force-removed on
    every path. Bare runs in the staged artifact dir through the same
    login-shell wrap the build uses. Compose, cloud and static deploys log a
    clear warn-and-skip naming the commands.
  • Commands are frozen on the deployment snapshot: redeploys and rollbacks
    replay that release's commands, and an absent field is byte-identical to
    today (tested).
  • 10-minute per-command budget (same as a bare build step); the timeout is
    checked before the exit code so a kill's 143 is not reported as the
    failure.

Deliberately deferred: dashboard UI field, per-service compose commands,
multi-role stacks (this issue's worker/scheduler half), auto-injected
framework defaults, #206 hooks.

Open design points where I'd take your preference (each is easy to flip):
whether rollback replays the phase; sh -c string vs argv; full env parity
including secrets; bare ordering vs linkPersistentPaths; warn-vs-refuse on
cloud.

Rebased onto current main; the migration is numbered 0107 so it sits after
0106_mcp_call_tracking.

How verified: tests at every seam (openship-config parsing, snapshot carry,
pipeline ordering and failure wiring, runtime-level non-zero-exit, timeout,
and output capture for docker and bare), each demonstrated to fail with its
behavior reverted. On the current base: packages/db 128 tests, the
release-phase suites, adapters and core suites all green.

…before cutover

An app project declares exactly one command (the start command), so
framework release steps had nowhere to run: php artisan migrate --force,
rails db:migrate, manage.py migrate never executed, and a stock Laravel
app's schema had to be bootstrapped by hand through the service terminal
(TODO.md "A generic release phase", adjacent to oblien#231).

Projects (and openship.json) can now declare releaseCommands: string[].
They are frozen onto the deployment's config snapshot: a redeploy that
rebuilds an older deployment runs the commands THAT release declared, not
today's project row: and run in the pipeline after a successful build,
before any cutover: before runDeployPipeline, before domain records,
while the previous version is still running and routed. Each command gets its own
log marker; a non-zero exit or a 10-minute per-command timeout fails the
deploy with the command's output, leaving the old version serving. An
absent field deploys byte-identically to before.

Execution per runtime: docker runs each command in a throwaway container
off the freshly built image: not an exec into the running (old-image)
deployment: with the deploy's env, scoped volume binds and project
network, but no published port and no restart policy, removed on every
path. Bare runs in the staged artifact dir through the same login-shell
wrap the build uses, with the start command's env. Compose, cloud and
static deploys log a clear warn-and-skip naming the commands rather than
silently dropping them.

A rollback does NOT replay them. pinnedAppImage(snapshot) is the exact
discriminator: only a rollback restore pins an already-built image, since
a plain redeploy strips pinned artifacts and rebuilds. Replaying is a
no-op in the good case, because the schema is already ahead of the
release coming back, and harmful in the bad one, because rollback is the
emergency path and a phase that fails the deploy is a new way for the
recovery itself to abort. This travels as a distinct deliberateSkipReason
rather than the existing unsupportedReason, which tells the operator to
run the commands by hand: the opposite of what a rollback wants.

Schema: nullable project.release_commands jsonb (no default, no
backfill), migration 0109 following the volumes pattern; parse/schema
support in openship.json with the published JSON Schema and docs updated
(Laravel/Rails/Django examples). The docs also state plainly that
attaching any service routes a project onto the services/compose
pipeline, where the phase is logged and skipped, so the limitation is not
left to be discovered in a deploy log. Dashboard UI intentionally
deferred.

Tests at every seam: openship-config parsing, snapshot carry in
build.service, phase ordering/failure wiring in the pipeline, and
runtime-level non-zero-exit/timeout/output-capture for docker and bare,
plus the deliberate skip (runs nothing, stays silent with no commands
declared, and omits the run-them-by-hand advice) and the wiring that
derives it from the pinned app image. Each was verified to fail with its
behavior reverted.
@Farahat612
Farahat612 force-pushed the feat/deploy-release-phase branch from db3c8b5 to 51c0f67 Compare August 18, 2026 15:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant