ADR 0004 / Deploy, release, and provisioning / 2 min read

A guarded shell deploy over a hosted CI runner

Status: Accepted · in production (host-side execution; amended 2026-10-15 for the automatic trigger)

Context

A single-server PHP application needs a way to go from "merged" to "live" safely. The industry-default answer is a hosted CI/CD runner. For one app on one server, that adds a moving part (runner availability, secrets shipped to a third party, network round-trips) to solve a problem that is mostly local: get the right commit onto this box without two deploys colliding or a bad ref going out.

Decision

A server-side deploy script executes on the box with explicit guardrails. The original entry point was a manual invocation; the amendment below separates that execution decision from the later automatic trigger:

  • Dirty-worktree rejection - refuse to deploy over uncommitted local changes.
  • flock-based serialization - only one deploy at a time; concurrent invocations fail fast instead of interleaving.
  • Ancestor-only ref enforcement - the target must be reachable from the remote default branch, so a divergent side-branch ref is refused unless an operator explicitly overrides it. This permits an intentional rollback to an earlier commit on that branch.
  • Post-deploy healthcheck - hit the app and confirm it's serving before calling the deploy done.
  • Rollback pointer - record the previous SHA and expose a one-command rollback to it.
  • Structured deploy log - every deploy/failure appends who, what, when.

Consequences

  • The direct manual path has no hosted-runner dependency. The automatic request path now does. Execution remains on the host in both cases.
  • Auditable and legible - the guarded script keeps the host-side steps readable. The console's deployment history records the larger operation, including the checks and migrations around that script.
  • Costs: I own the host-side guardrails. Tests and static analysis run in hosted CI; the automatic pipeline now verifies their results before acting, rather than leaving verification separate from deployment.

Amendment - 2026-10-15

A push to the default branch now requests production deployment from GitHub Actions. The hosted runner presents a short-lived OIDC assertion and holds no deployment credential. The host verifies the request and the exact commit's required checks under the production lock, then runs its own pipeline: assets, migrations, the guarded shell script, and post-deploy verification.

The original reason for keeping execution on the host still holds. The desire to ship reviewed merges without a separate deploy click changed the trigger, so the original claim of no hosted-runner dependency no longer describes the automatic path. Pull requests and engineering review still control code promotion. ADR 0058 records the new request boundary.

When I'd revisit

Multiple servers, blue/green or canary needs, or a team where many people deploy - any of those tips the balance back toward a managed pipeline.