Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/openship-config/references/fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ value. Validated by `openship config validate` (same parser the deploy uses).
| `installCommand` | string | Dependency install command. |
| `buildCommand` | string | Build command. |
| `startCommand` | string | Production start command. |
| `releaseCommands` | string[] | Commands run ONCE per deploy, after the build and before the new version goes live (migrations, cache warms). Each runs with the start command's env; a non-zero exit fails the deploy and the previous version keeps serving. Laravel: `["php artisan migrate --force", "php artisan optimize", "php artisan storage:link", "php artisan reload"]`. Omit for no release phase (the default — nothing is auto-injected). Skipped with a logged warning on Openship Cloud, static sites and compose projects. |
| `outputDirectory` | string | Build output dir (`dist`, `.next`, `build`, `out`, …). |
| `buildImage` | string | Build Docker image (e.g. `node:22`). |
| `productionPaths` | string[] | Paths shipped as the production artifact. |
Expand Down
48 changes: 33 additions & 15 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,23 +382,41 @@ the JS asset stage also landed; the storage section below has what's left of tho
### A generic release phase

Commands that run ONCE per deploy, after build and before cutover, failing the
deploy on error. `queue:work`, `schedule:run`, `migrate --force`, `optimize` and
`storage:link` appear nowhere in the tree today, so migrations, scheduled tasks
and queued jobs silently never run.

- [ ] Add release commands to the project + `openship.json`, snapshot them onto
the deployment, and run them from the deploy pipeline between build and
activate (`apps/api/src/modules/deployments/build-pipeline.ts` — the same
seam `deployConfig` is assembled in).
- [ ] Laravel's set for 13.x: `migrate --force`, `optimize` (config/events/routes/
views), `storage:link`, and `reload` (13's umbrella for cycling long-running
services — supersedes `queue:restart` for deploys, also covers Reverb and
Octane).
deploy on error.

**Shipped (v1).** `openship.json` `releaseCommands` (a LIST — a framework's
release set is several independent steps, each with its own log marker and its
own attributable failure) → `project.release_commands` → frozen on the
deployment snapshot → run in `executeServerDeploy`
(`apps/api/src/modules/deployments/build-pipeline.ts`) right after `deployConfig`
is assembled and before the first domain row or `runDeployPipeline`, so a failed
migration leaves the previous version running and routed. Docker runs each
command in a throwaway container off the new image (deploy env + volumes +
project network, no published port, no restart policy); bare runs it in the
staged release dir through a login shell. Ordering / fail-fast / skip live in
`deployments/release-phase.ts`. Nothing is auto-injected per framework.

- [ ] Laravel's set for 13.x is DOCUMENTED, not injected: `migrate --force`,
`optimize` (config/events/routes/views), `storage:link`, and `reload` (13's
umbrella for cycling long-running services — supersedes `queue:restart` for
deploys, also covers Reverb and Octane). Auto-injection on detection is the
open question.
- [ ] No dashboard field yet — the phase is reachable through `openship.json`,
`POST /projects` / `PATCH /projects/:id` and `POST /:id/options` only.
- [ ] Compose/services projects skip it with a logged warning: a release command
there would have to name a SERVICE to run in, which v1 doesn't model.
Openship Cloud skips it too (no one-off execution primitive), as do static
deploys (no runtime).
- [ ] Bare runs the command BEFORE `linkPersistentPaths`, so a path that becomes a
`shared/` symlink is still a plain dir at release time. Migrating a DATABASE
is unaffected; a command that writes a file expected to survive the release
swap (a SQLite file under `storage/`) is not.
- [ ] A rollback/redeploy replays the TARGET release's frozen commands. Fine for
idempotent migrations, an open question for anything else.
- [ ] Not the same thing as `#206` deploy hooks: those are an inbound trigger that
STARTS a deploy; this runs DURING one.
- [ ] Until this exists, a stock SQLite Laravel app still needs its migrations run
by hand (the service terminal can do it) — a persistent volume stops data
LOSS, it doesn't bootstrap a schema.
- [ ] `queue:work` and `schedule:run` are still unexpressible — they're roles, not
release steps. See multi-role stacks below.

### Multi-role stacks

Expand Down
54 changes: 54 additions & 0 deletions apps/api/src/modules/deployments/build-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import { onFailure, onSuccess, onCancelled, reportPipelineError, setDeploymentSt
import { auditPorts } from "./port-audit.service";
import { verifyDeployedContainers } from "./stability-audit.service";
import { resolveReadinessGate, runReadinessGate, type ResolvedReadinessGate } from "./readiness-gate";
import { RELEASE_COMMAND_TIMEOUT_MS, resolveReleaseCommands, runReleasePhase } from "./release-phase";
import { auditStaticOutput, staticOutputTargets } from "./output-audit.service";
import { createBuildConfig } from "./build-config";
import { pinnedAppImage, pinnedStaticDir, snapshotNeedsGitSource } from "./pinned-artifacts";
Expand Down Expand Up @@ -913,6 +914,15 @@ async function executeBuildAndDeploy(project: Project, dep: Deployment, buildSes
});
}

// A compose project's release phase would have to name a SERVICE to run in
// (there is no single app image), which v1 doesn't model — so say so
// instead of dropping the declaration on the floor.
await runReleasePhase({
commands: resolveReleaseCommands(snapshot.releaseCommands),
unsupportedReason: "Release commands aren't supported on services/compose projects yet",
log: (message, level) => logger.log(`${message}\n`, level),
});

// Clone-on-server for compose: open one repo-pinned relay for the whole
// fan-out (all services share the same repo), thread its helper path into
// every service buildConfig, and close it once the pipeline settles.
Expand Down Expand Up @@ -1128,6 +1138,14 @@ async function executeStaticEdgeDeploy(
): Promise<void> {
const { ctx, project, dep, snapshot, buildSessionId, routeState, buildResult, envMap, prodResources, logger } = phase;

// Pages is a file upload, not a workload — there is nothing to run a command
// in. Declared commands are named in the log rather than silently dropped.
await runReleasePhase({
commands: resolveReleaseCommands(snapshot.releaseCommands),
unsupportedReason: "A static edge (Pages) deploy has no runtime to run release commands in",
log: (message, level) => logger.log(`${message}\n`, level),
});

logger.step("deploy", "running", "Deploying to edge (static)...");

const staticResult = await runtime.deployStatic({
Expand Down Expand Up @@ -1735,8 +1753,44 @@ async function executeServerDeploy(phase: DeployPhaseInputs): Promise<void> {
// Bare uses this to hard-link identical files across releases.
// Other runtimes ignore it.
previousDeploymentId: project.activeDeploymentId ?? undefined,
// Frozen on the snapshot, so a redeploy of an old deployment replays the
// commands THAT release declared. Absent on pre-release-phase rows.
releaseCommands: snapshot.releaseCommands,
};

// ── Release phase: after the build, before ANY cutover ────────────────────
// Placed here on purpose. Nothing below has touched the live deployment yet —
// the previous version is running and routed, no domain row has been created,
// runDeployPipeline hasn't been entered — so a failed migration aborts with the
// old version still serving and nothing to roll back. It is the same boundary
// for every runtime this path serves: docker/bare/static all activate inside
// runDeployPipeline's `activate` step, which is strictly after this.
const releaseCommands = resolveReleaseCommands(deployConfig.releaseCommands);
if (releaseCommands.length > 0) {
try {
await runReleasePhase({
commands: releaseCommands,
// A static file-serve has no process and no image to run a command in,
// and cloud has no one-off execution primitive at all — both fall through
// to the logged skip rather than pretending the commands ran.
run:
!isStaticFileServe && runtime.supports("releaseCommand") && runtime.runReleaseCommand
? (command) =>
runtime.runReleaseCommand!(deployConfig, command, logger.callback, {
timeoutMs: RELEASE_COMMAND_TIMEOUT_MS,
})
: undefined,
unsupportedReason: isStaticFileServe
? "A static site has no runtime to run release commands in"
: `The "${runtime.name}" runtime can't run release commands yet`,
log: (message, level) => logger.log(`${message}\n`, level),
});
} catch (err) {
await onFailure(ctx, safeErrorMessage(err), buildResult.durationMs);
return;
}
}

// Resolve the previous deployment + its runtime so we can deactivate it cleanly.
const prevDep = project.activeDeploymentId
? await repos.deployment.findById(project.activeDeploymentId)
Expand Down
13 changes: 13 additions & 0 deletions apps/api/src/modules/deployments/build.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,14 @@ export interface DeploymentConfigSnapshot {
rootDirectory: string;
port: number;
startCommand: string;
/**
* Commands run once between this deployment's build and its cutover. Frozen
* here for the same reason `volumes` is: a redeploy of an OLD deployment must
* replay the commands THAT release declared, not what the project says today.
* Absent on every row written before the field existed — and absent means no
* release phase, so those redeploy exactly as they always did.
*/
releaseCommands?: string[];
resources: ResourceConfig | null;
buildResources: ResourceConfig | null;
/** Whether the project needs a running server (false = static, deploy via Pages) */
Expand Down Expand Up @@ -377,6 +385,11 @@ export function buildConfigSnapshot(
rootDirectory: project.rootDirectory || "",
port: project.port ?? 3000,
startCommand: project.startCommand!,
// Only carried when the project declared some: an absent key keeps the
// snapshot byte-identical to a pre-release-phase one.
...(Array.isArray(project.releaseCommands) && project.releaseCommands.length > 0
? { releaseCommands: project.releaseCommands as string[] }
: {}),
resources: (project.resources as ResourceConfig) || null,
buildResources: (project.buildResources as ResourceConfig) || null,
hasServer: project.hasServer ?? !!project.startCommand?.trim(),
Expand Down
8 changes: 8 additions & 0 deletions apps/api/src/modules/deployments/prepare.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,11 @@ export interface ProjectInfo {
* the pipeline does when the project has no `readiness`.
*/
readiness?: OpenshipReadiness;
/**
* Declared release commands — run once per deploy between build and cutover.
* Absent means no release phase, which is the default for every project.
*/
releaseCommands?: string[];
}

/** A `domains[]` entry normalized to the `CreateProjectBody.publicEndpoints` shape. */
Expand Down Expand Up @@ -436,6 +441,8 @@ function applyOpenshipOverlay(info: ProjectInfo, config: OpenshipConfig | undefi
}
if (config.resources) info.resources = config.resources;
if (config.readiness) info.readiness = config.readiness;
// Declared `[]` is meaningful (release phase off), so test for presence.
if (config.releaseCommands) info.releaseCommands = config.releaseCommands;

// Declared compose services replace detection: the project IS a services
// project. runtimeMode="docker" then falls out of buildProductionProjectInput's
Expand Down Expand Up @@ -500,6 +507,7 @@ export function projectInfoToScanResponse(result: ProjectInfo) {
...(result.publicEndpoints && { publicEndpoints: result.publicEndpoints }),
...(result.resources && { resources: result.resources }),
...(result.readiness && { readiness: result.readiness }),
...(result.releaseCommands && { releaseCommands: result.releaseCommands }),
...(result.rootEnv && Object.keys(result.rootEnv).length > 0 && { rootEnv: maskEnv(result.rootEnv) }),
...(result.routing && { routing: result.routing }),
...(result.monorepoWorkspace && { monorepoWorkspace: result.monorepoWorkspace }),
Expand Down
92 changes: 92 additions & 0 deletions apps/api/src/modules/deployments/release-phase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* The deploy-time release phase — commands that run ONCE per deploy, after the
* build and before the new version is activated.
*
* OFF IS THE DEFAULT: a project with no `releaseCommands` runs nothing here and
* its deploy is byte-identical to what it was before this module existed. What
* it buys the projects that do declare them is the thing an app stack could not
* express at all until now — an app declares exactly one start command, so
* `php artisan migrate --force`, `rails db:migrate` and `manage.py migrate`
* never ran, and a stock Laravel app's schema had to be bootstrapped by hand
* through the service terminal.
*
* Unlike the readiness gate, a failure here has no warn/fail choice: a release
* command exists to be a precondition of serving the new version, so a non-zero
* exit FAILS the deploy. That is the safe direction — the previous version is
* still running and still routed at this point in the pipeline, so a failed
* migration leaves the old app serving instead of cutting over to one that
* cannot talk to its database.
*/

import { SYSTEM, safeErrorMessage } from "@repo/core";

/**
* Normalize a project's / snapshot's declared commands into the list the phase
* actually runs. Blank entries are dropped (a settings form's empty row), so
* `[" "]` resolves to "nothing to run" rather than a shell no-op that still
* costs a container.
*/
export function resolveReleaseCommands(declared: string[] | null | undefined): string[] {
if (!Array.isArray(declared)) return [];
return declared.map((command) => command.trim()).filter((command) => command.length > 0);
}

/**
* Run the release phase.
*
* `run` is injected — it's `runtime.runReleaseCommand` bound to the deploy
* config at the call site — so the ordering, the log markers, the fail-fast and
* the unsupported-runtime skip are all testable without a runtime, a container
* or a daemon.
*
* Returns nothing and throws on the first failure: the caller turns that into a
* failed deployment. `run: undefined` means the runtime has no such primitive
* (cloud) — the phase then logs a warning naming what it skipped and returns,
* because refusing the deploy outright would break every existing cloud project
* the moment someone added a release command for their self-hosted target.
*/
export async function runReleasePhase(opts: {
commands: string[];
/** Runs ONE command; rejects with the command's output on a non-zero exit. */
run?: (command: string) => Promise<void>;
/** Why `run` is missing — logged so a skipped migration is never silent. */
unsupportedReason?: string;
log: (message: string, level?: "info" | "warn" | "error") => void;
}): Promise<void> {
const { commands, run, unsupportedReason, log } = opts;
if (commands.length === 0) return;

if (!run) {
log(
`${unsupportedReason ?? "This runtime cannot run release commands"} — skipping ` +
`${commands.length} release command${commands.length === 1 ? "" : "s"}: ` +
`${commands.join(", ")}. Run them by hand before this version serves traffic.`,
"warn",
);
return;
}

log(
`Release phase: running ${commands.length} command${commands.length === 1 ? "" : "s"} ` +
`before this version goes live (a failure here fails the deploy and leaves the ` +
`previous version serving).`,
);

for (const [index, command] of commands.entries()) {
const marker = `[release ${index + 1}/${commands.length}]`;
log(`${marker} $ ${command}`);
try {
await run(command);
} catch (err) {
const message = safeErrorMessage(err);
log(`${marker} failed: ${message}`, "error");
throw new Error(`Release command failed: ${command}\n${message}`);
}
log(`${marker} done.`);
}

log("Release phase complete.");
}

/** Per-command budget. Re-exported so the pipeline and its tests name one number. */
export const RELEASE_COMMAND_TIMEOUT_MS = SYSTEM.DEPLOYMENTS.RELEASE_COMMAND_TIMEOUT_MS;
14 changes: 14 additions & 0 deletions apps/api/src/modules/projects/project-crud.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,9 @@ function buildProductionProjectInput(
rootDirectory: data.rootDirectory,
composePath: normalizeComposePath(data.composePath),
startCommand: data.startCommand,
// undefined (not declared) leaves the column NULL = no release phase, which
// is what every project did before the phase existed.
releaseCommands: data.releaseCommands ?? null,
buildImage: data.buildImage,
productionMode: workload.productionMode,
port: data.port ?? 3000,
Expand Down Expand Up @@ -1081,6 +1084,7 @@ export async function ensureProject(
if (data.rootDirectory !== undefined) update.rootDirectory = data.rootDirectory;
if (data.composePath !== undefined) update.composePath = normalizeComposePath(data.composePath);
if (data.startCommand !== undefined) update.startCommand = data.startCommand;
if (data.releaseCommands !== undefined) update.releaseCommands = data.releaseCommands;
if (data.buildImage !== undefined) update.buildImage = data.buildImage;
if (data.port !== undefined) update.port = data.port;
// Workload axis (workloadType / hasServer / productionMode) — one choke
Expand Down Expand Up @@ -1650,6 +1654,7 @@ export async function createProjectEnvironment(
rootDirectory: base.rootDirectory,
composePath: base.composePath,
startCommand: base.startCommand,
releaseCommands: base.releaseCommands,
buildImage: base.buildImage,
productionMode: base.productionMode,
port: base.port,
Expand Down Expand Up @@ -2121,6 +2126,15 @@ export async function updateOptions(
update.composePath = normalizeComposePath(composePath);
}
if (options.startCommand !== undefined) update.startCommand = options.startCommand;
// Array-or-null only, same rule as `volumes`: a bare string here would run as
// one nonsense command between build and cutover, and a failure there fails the
// deploy. null/[] turns the release phase off.
if (options.releaseCommands !== undefined) {
if (options.releaseCommands !== null && !Array.isArray(options.releaseCommands)) {
throw new ValidationError("releaseCommands must be an array of commands, or null");
}
update.releaseCommands = options.releaseCommands;
}
if (options.productionPort !== undefined) update.port = options.productionPort;
if (options.packageManager !== undefined) update.packageManager = options.packageManager;
if (options.buildImage !== undefined) update.buildImage = options.buildImage;
Expand Down
13 changes: 13 additions & 0 deletions apps/api/src/modules/projects/project.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,15 @@ export const CreateProjectBody = Type.Object({
*/
composePath: Type.Optional(Type.String({ maxLength: 300 })),
startCommand: Type.Optional(Type.String({ maxLength: 500 })),
/**
* Commands run ONCE per deploy, between the build and the cutover — migrations,
* cache warms. A non-zero exit fails the deploy. Omit to keep the current value;
* send `[]` (or null) to turn the release phase off. Mirrors `openship.json`'s
* `releaseCommands`.
*/
releaseCommands: Type.Optional(
Type.Union([Type.Null(), Type.Array(Type.String({ maxLength: 1000 }), { maxItems: 20 })]),
),
buildImage: Type.Optional(Type.String({ maxLength: 200 })),
productionMode: Type.Optional(
Type.Union([Type.Literal("host"), Type.Literal("static"), Type.Literal("standalone")]),
Expand Down Expand Up @@ -627,6 +636,10 @@ export const SetOptionsBody = Type.Object(
/** Compose file location; `null` clears it and restores root detection. */
composePath: Type.Optional(Type.Union([Type.String({ maxLength: 300 }), Type.Null()])),
startCommand: Type.Optional(Type.String()),
/** Deploy-time release commands; `null`/`[]` turns the release phase off. */
releaseCommands: Type.Optional(
Type.Union([Type.Array(Type.String({ maxLength: 1000 }), { maxItems: 20 }), Type.Null()]),
),
productionPort: Type.Optional(Type.Union([Type.Number(), Type.String()])),
packageManager: Type.Optional(Type.String()),
buildImage: Type.Optional(Type.String()),
Expand Down
Loading