Skip to content

Release & Deploy Architecture

How code gets from a merged PR to a running environment under OnRamp's trunk-based release process. This is the system reference; the day-to-day "how do I ship / hotfix" lives in Releasing & Hotfixing.

StatusLive — trunk model is the active release process
AudienceEngineers shipping, releasing, hotfixing, or debugging a deploy
Lives in.github/workflows/, .github/actions/, .github/scripts/determine-environment.js

Mental model

main is the only long-lived branch. Merging ships to dev only. Staging and prod are reached by running a workflow, not by merging into an environment branch — promotion ≠ merge. This removes branch drift (dev/staging/prod diverging), rebuild-per-env, and the hotfix back-merge ritual.

Three facts hold it together:

  1. The commit is the identity. The same SHA flows dev → staging → prod unchanged — no per-environment rebuilds.
  2. The git tag is the release value. Clean vM.N.0/vM.N.P tags are minted only by the promote workflows. dev/staging carry a computed vM.N.x-<count> snapshot (with a throwaway snapshot/… tag for visibility), never a release tag.
  3. GitHub Deployments are the state — the authoritative record of "what's deployed where," written after every deploy.

Branch & promotion flow

mermaid
flowchart LR
    PR["Merged PR"] --> MAIN["main (trunk)"]
    MAIN -->|"push to main (auto)"| DEV["dev01"]
    DEV -.->|"Promote to Staging (blank = latest main)"| STAGE["stage"]
    STAGE -.->|"Promote to Production (approved)"| PROD["prod / demo / gdpr-prod"]

    classDef auto fill:#d1fae5,stroke:#059669,color:#065f46
    classDef manual fill:#fee2e2,stroke:#dc2626,color:#991b1b
    class DEV auto
    class STAGE,PROD manual
  • Merge to main → dev. Automatic, the only automatic deploy. Each dev build computes its version and creates a snapshot/<version> snapshot tag (e.g. snapshot/v4.2.x-13) used as the deployment ref — so the version is visible in the Deployments UI and selectable later.
  • Promote to Staging (🧪 Promote to Staging, promote-stage.yml, workflow_dispatch). The staging twin of promote-prod, on the shared promote model: the workflow always runs from main ("Use workflow from" is guarded), a blank version input promotes the latest main, and a snapshot/v4.2.x-13 tag promotes that exact snapshot (invalid/pruned tags fail fast and list the current ones). It's a control plane: after validating it dispatches the deploy worker (⚙️ Deploy to Staging (internal), promote-staging.yml) at the chosen ref — so the build pipeline that runs belongs to the commit being deployed, while the promote logic is always current. No release tag minted — the stage deployment is recorded against the same snapshot/<version> tag (created on the spot if the commit never went through the dev flow), so the Deployments UI shows the snapshot you can later promote to prod, not a bare sha.
  • Promote to Production (🚀 Promote to Production, promote-prod.yml; the human gate is the deliberate dispatch itself — see Environments below, required reviewers are not available on the org's plan). Promotes the staging-validated commit — it resolves the SHA of the latest successful stage deployment (not main-tip, since staging lags main), mints the next clean vM.N.0 tag on that commit, publishes a Release (the audit artifact), and explicitly dispatches deploy-release.yml at the tag ref → prod + demo + gdpr-prod. The dispatch is required: releases created with the repo GITHUB_TOKEN never fire release:-triggered workflows (GitHub's recursion guard). A human-published Release still deploys via the release: published event.

Both promotes are driven by agent skills as the primary entry point/promote-stage and /promote-prod (deliberately separate, so stage and prod can't be fat-fingered into each other). The skills gather context (what's on each environment, the commit delta), ask default-vs-explicit-target, validate, dispatch, and watch the runs. The workflows stay directly runnable from the Actions UI as the no-agent fallback; the runbook documents both paths in that order.

Version scheme

Standard semver, patch slot reserved for hotfixes. Clean release tags mint only on prod promotion; dev/staging carry a computed snapshot.

StreamIdentifierCreated
Dev + stagingv4.2.x-13 (release line + commits-since)Computed at build; a snapshot/v4.2.x-13 tag is created
Trunk releasev4.2.0, v4.3.0, … (minor bump, patch 0)Prod promotion (mint-version)
Hotfixv4.2.1, v4.2.2, … (patch increments)Promote-hotfix, derived from the branch name
Prod re-deploy / rollbacksame release tagNo new tag

Reading v4.2.x-1313 commits past the v4.2.0 release. That count is the release-size signal. The x patch slot is deliberate: a snapshot is an unreleased build of the 4.2 line, not the v4.2.0 release — writing v4.2.0-13 made it read like a build of v4.2.0. There's no trailing -<sha> — on the squash-merge trunk the count is unique to one commit, and the snapshot/v4.2.x-13 tag links straight to it.

Snapshot tags are namespaced + ephemeral. dev + stage deploys create snapshot/<version> (under snapshot/, not the v* release namespace), so the release tag list stays clean and the Release Tags ruleset doesn't gate them. cleanup-snapshot-tags.yml prunes snapshot/* tags older than 30 days.

How the version is computed

Two composite actions own this (both require fetch-depth: 0; they force-fetch tags internally):

  • compute-versiongit describe --tags --long --match 'v[0-9]*.[0-9]*.0' (only clean release tags, so snapshot/* and legacy -prod tags don't pollute the count), then drops the -g<sha> segment and rewrites the patch digit to xv4.2.x-13. On a release commit it emits the bare tag (v4.2.0). Outputs version, sha, is_release.
  • mint-version — reads VERSION_MAJOR (tracked file at repo root, currently 4, CODEOWNERS-gated), finds the highest v4.*.0 tag, bumps the minor, pushes the new tag. No fallback — errors loudly if no clean vM.N.0 tag exists.
ComponentOwnerWhen
MajorVERSION_MAJOR file, bumped by reviewed PRBreaking-change milestone
Minormint-version, auto-incrementProd promotion only
Patch0 for trunk; hotfix path owns > 0; x on snapshots
Snapshot -<count>compute-versionBuild time (dev/staging)

The version is frozen at deploy time on the GitHub Deployment; the snapshot/<version> tag pins the exact commit. Minting v4.3.0 later doesn't rewrite a prior dev build's v4.2.x-13 — the commit is the join key.

Deploy fan-out

build-and-deploy.yml (reusable) is parameterized by target_environment. It builds the Flask + webserver images once, then fans out:

mermaid
flowchart TD
    BD["build-and-deploy.yml<br/>(target_environment)"] --> FLASK["build flask + webserver images"]
    FLASK --> DUPLO["duplo-deploy (web app)"]
    FLASK --> AGENTS["deploy-agents (onramp-agents runtimes)"]
    DUPLO --> REC["record-deployment"]
    AGENTS --> REC
    REC --> GHD["GitHub Deployment + commit status + run summary"]
  • target_environment: prod → web app to [prod-usw2, prod-gdpr-ir, demo-usw2] and agents to [prod, demo, gdpr-prod] (parallel, fail-fast: false).
  • stage[stage]; dev[dev].
  • record-deployment writes a GitHub Deployment (ref = the version tag, so the chip shows snapshot/v4.2.x-13 / v4.3.0 rather than a bare sha), a deploy/<env> commit status carrying the version (visible on the commit + PRs), and a run-summary line. Hotfix tooling reads the latest successful prod Deployment to resolve the live prod SHA.

Tag → environment routing

determine-environment.js (inside deploy-release.ymlenvironment-setup.yml) is additive: legacy -(prod|stage|dev) suffix branches are preserved, and a non-prerelease clean-semver tag routes to prod.

TagPrereleaseRoutes to
v4.2.0 (trunk release) / v4.2.1 (hotfix)falseprod
v4.1.0-prod (legacy)falseprod
anytruedev

Environments

GitHub Environments gate which refs may deploy and who must approve:

  • Deployment branch/tag allow-lists: stage allows main; prod/demo/ gdpr-prod allow clean v* tags (so a published release can deploy). Without these, the env-bound agent jobs would be blocked at the gate.
  • Required reviewers are NOT configured: that protection rule is Enterprise-only on private repos and the org is on the Team plan (the API attempt 422s — see preview-shared-apply.yml). The human control on promote-prod / deploy-release is the deliberate dispatch itself (actor + ref + timestamp recorded on the run). If the org ever moves to a plan that supports them, reviewers on prod (and the other production-tier envs) would additionally provide a pause-for-approval gate.
  • Each env injects DUPLO_TENANT_NAME + DUPLO_TOKEN/AGENT_* secrets.

Hotfix flow

Hotfixes never touch main directly — a short-lived branch is cut from the deployed prod SHA, fixed, promoted straight to prod, then forward-ported.

mermaid
sequenceDiagram
    actor Eng
    participant CH as Create Hotfix
    participant HB as hotfix/v4.N.P
    participant PH as Promote Hotfix
    participant Prod
    participant FP as Forward-port
    participant Main as main

    Eng->>CH: dispatch (ticket)
    CH->>Prod: read live prod SHA (GitHub Deployment)
    CH->>HB: branch off prod SHA, next patch
    Eng->>HB: commit fix
    Eng->>PH: dispatch (hotfix branch)
    PH->>PH: validate name, CI green, smoke
    PH->>Prod: tag v4.N.P, publish Release, deploy
    PH->>FP: trigger forward-port
    FP->>Main: cherry-pick onto forward-port branch, open PR (automation App)
    FP->>Main: approve + auto-merge on green
    PH->>HB: delete branch
  • 🔥 Create Hotfix Branch — resolves the live prod SHA from the latest successful prod Deployment, computes the next patch, branches hotfix/v4.N.P.

  • 🚑 Promote Hotfix to Production — validates the branch, checks CI green (python-test / frontend-ci / e2e run on hotfix/** pushes so the gate has real checks to read), runs a smoke suite (placeholder until an ephemeral-env harness exists), tags + publishes the Release and dispatches deploy-release.yml at the tag (→ prod, skips staging by design), triggers forward-port, deletes the branch. The skip_forward_port input skips the forward-port PR when the hotfix commits were cherry-picked from an existing PR — merged (the auto cherry-pick back would be an empty no-op) or open (that PR is the path to main and inherits the forward-port SLA; no branch rename — the drift-sentinel keys on the tag, not branch names, so just label the PR forward-port). Atypical; never set it on a fresh fix.

  • forward-port-hotfix (workflow_call) — cherry-picks onto forward-port/<ticket> off main; clean → opens a labelled PR, approves it, and enables auto-merge (squash) so it lands as soon as CI is green; conflict → draft PR with markers assigned to the author (never auto-merged).

    Which token opens the PR is load-bearing. GitHub's recursion guard is specific to GITHUB_TOKEN: a PR opened with it never fires pull_request workflows, so its required checks sit at "Expected" forever and only a ruleset bypass could merge it. An App installation token is not GITHUB_TOKEN and its events do start workflow runs — the same property dependabot-lockfile-sync.yml relies on. So the onramp-github-automation App opens the PR, real CI runs, and auto-merge lands it through the front door with no bypass grant. That forces the approval onto the other identity: GitHub rejects a self-approval, so github-actions[bot] approves what the App authored (org setting "Allow GitHub Actions to create and approve pull requests", already on).

    Credentials: AUTOMATION_BOT_APP_ID (App ID or Client ID) + AUTOMATION_BOT_APP_PRIVATE_KEY org secrets; the installation needs Contents + Pull requests R/W, and Workflows R/W for hotfixes that touch .github/workflows/**. Absent, the PR still opens (as github-actions[bot], unchecked) and the auto-merge job fails loudly rather than the fix going missing.

    Enabling auto-merge is not the same as merging: a forward-port touching a CODEOWNERS path needs a code-owner approval the bot's cannot satisfy, and auto-merge would sit armed but never fire. The job checks reviewDecision after arming and comments on the PR when it is still REVIEW_REQUIRED, so that stall surfaces immediately rather than at the drift-sentinel's 24h mark.

Deferred (tracked follow-up): the "destructive migration pending in trunk not in prod" gate on create-hotfix. Interim mitigation: expand-and-contract discipline + manual vigilance — see the migration runbook.

Guardrails

GuardrailMechanismEffect
Hotfix can't land on trunk via mergeforbid-hotfix-merge.yml (PR check, required on main)Fails any PR whose head is hotfix/*
Forward-port lands without human toilforward-port-hotfix.yml auto-merge job (App-authored PR, github-actions[bot] approves, auto-merge squashes on green)Reconciliation completes without a human; a red check holds the merge, and a setup failure is loud (red run + PR comment pinging the author), never silent
Forward-port can't be silently skippeddrift-sentinel.yml (daily cron)Backstop for the above: pages if a prod commit has been off main past a 24h SLA; the 7-day scan is backstop breadth
Release tags stay cleanRelease Tags ruleset (^v\d+\.\d+\.(0|[1-9]\d*)$)Only well-formed vM.N.0/vM.N.P in the v* namespace; snapshot/* is unaffected
main protectedmain ruleset (cloned from the old dev ruleset's checks + forbid-hotfix-merge, squash-only, code-owner review)Trunk integrity
Hotfix branches created only by automationbranch ruleset on hotfix/*Creation restricted to the create-hotfix actor
State is authoritativerecord-deployment → GitHub Deployments"What's in prod?" is a query, not a guess

Workflow inventory

WorkflowTriggerRole
🛠️ Deploy Devpush mainBuild + deploy to dev; tag snapshot/<version>; record
🧪 Promote to Stagingdispatch (blank version = latest main, or a snapshot/* tag)Validate the selection → dispatch the staging deploy worker at that ref
⚙️ Deploy to Staging (internal)dispatch (by Promote to Staging, at the target ref)Build + deploy the dispatched ref to staging
🚀 Promote to Productiondispatch (prod approval; blank ref = staging-validated)Mint tag on the staging-validated commit → Release → prod fan-out
🔥 Create Hotfix BranchdispatchBranch off live prod SHA
🚑 Promote Hotfix to ProductiondispatchTag + deploy hotfix to prod, forward-port
forward-port-hotfixworkflow_callCherry-pick hotfix → main PR, then approve + merge it
forbid-hotfix-mergepull_request → mainBlock direct hotfix merges
drift-sentinelcron + dispatchDetect un-forward-ported prod commits
cleanup-snapshot-tagscron + dispatchPrune snapshot/* tags older than 30d
build-and-deployworkflow_callBuild once, fan out, record deployment
deploy-releaserelease: publishedFires on a minted Release → environment-setup → build-and-deploy

Internal documentation — gated behind Cloudflare Access.