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.
| Status | Live — trunk model is the active release process |
| Audience | Engineers 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:
- The commit is the identity. The same SHA flows dev → staging → prod unchanged — no per-environment rebuilds.
- The git tag is the release value. Clean
vM.N.0/vM.N.Ptags are minted only by the promote workflows. dev/staging carry a computedvM.N.x-<count>snapshot (with a throwawaysnapshot/…tag for visibility), never a release tag. - GitHub Deployments are the state — the authoritative record of "what's deployed where," written after every deploy.
Branch & promotion flow
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 asnapshot/<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 frommain("Use workflow from" is guarded), a blankversioninput promotes the latest main, and asnapshot/v4.2.x-13tag 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 samesnapshot/<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 successfulstagedeployment (notmain-tip, since staging lagsmain), mints the next cleanvM.N.0tag on that commit, publishes a Release (the audit artifact), and explicitly dispatchesdeploy-release.ymlat the tag ref → prod + demo + gdpr-prod. The dispatch is required: releases created with the repoGITHUB_TOKENnever firerelease:-triggered workflows (GitHub's recursion guard). A human-published Release still deploys via therelease: publishedevent.
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.
| Stream | Identifier | Created |
|---|---|---|
| Dev + staging | v4.2.x-13 (release line + commits-since) | Computed at build; a snapshot/v4.2.x-13 tag is created |
| Trunk release | v4.2.0, v4.3.0, … (minor bump, patch 0) | Prod promotion (mint-version) |
| Hotfix | v4.2.1, v4.2.2, … (patch increments) | Promote-hotfix, derived from the branch name |
| Prod re-deploy / rollback | same release tag | No new tag |
Reading v4.2.x-13 → 13 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-version—git describe --tags --long --match 'v[0-9]*.[0-9]*.0'(only clean release tags, sosnapshot/*and legacy-prodtags don't pollute the count), then drops the-g<sha>segment and rewrites the patch digit tox→v4.2.x-13. On a release commit it emits the bare tag (v4.2.0). Outputsversion,sha,is_release.mint-version— readsVERSION_MAJOR(tracked file at repo root, currently4, CODEOWNERS-gated), finds the highestv4.*.0tag, bumps the minor, pushes the new tag. No fallback — errors loudly if no cleanvM.N.0tag exists.
| Component | Owner | When |
|---|---|---|
| Major | VERSION_MAJOR file, bumped by reviewed PR | Breaking-change milestone |
| Minor | mint-version, auto-increment | Prod promotion only |
| Patch | 0 for trunk; hotfix path owns > 0; x on snapshots | — |
Snapshot -<count> | compute-version | Build 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:
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-deploymentwrites a GitHub Deployment (ref = the version tag, so the chip showssnapshot/v4.2.x-13/v4.3.0rather than a bare sha), adeploy/<env>commit status carrying the version (visible on the commit + PRs), and a run-summary line. Hotfix tooling reads the latest successfulprodDeployment to resolve the live prod SHA.
Tag → environment routing
determine-environment.js (inside deploy-release.yml → environment-setup.yml) is additive: legacy -(prod|stage|dev) suffix branches are preserved, and a non-prerelease clean-semver tag routes to prod.
| Tag | Prerelease | Routes to |
|---|---|---|
v4.2.0 (trunk release) / v4.2.1 (hotfix) | false | prod |
v4.1.0-prod (legacy) | false | prod |
| any | true | dev |
Environments
GitHub Environments gate which refs may deploy and who must approve:
- Deployment branch/tag allow-lists:
stageallowsmain;prod/demo/gdpr-prodallow cleanv*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 onpromote-prod/deploy-releaseis the deliberate dispatch itself (actor + ref + timestamp recorded on the run). If the org ever moves to a plan that supports them, reviewers onprod(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.
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 successfulprodDeployment, computes the next patch, brancheshotfix/v4.N.P.🚑 Promote Hotfix to Production— validates the branch, checks CI green (python-test/frontend-ci/e2erun onhotfix/**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 dispatchesdeploy-release.ymlat the tag (→ prod, skips staging by design), triggers forward-port, deletes the branch. Theskip_forward_portinput 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 tomainand inherits the forward-port SLA; no branch rename — the drift-sentinel keys on the tag, not branch names, so just label the PRforward-port). Atypical; never set it on a fresh fix.forward-port-hotfix(workflow_call) — cherry-picks ontoforward-port/<ticket>offmain; 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 firespull_requestworkflows, so its required checks sit at "Expected" forever and only a ruleset bypass could merge it. An App installation token is notGITHUB_TOKENand its events do start workflow runs — the same propertydependabot-lockfile-sync.ymlrelies on. So theonramp-github-automationApp 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, sogithub-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_KEYorg secrets; the installation needs Contents + Pull requests R/W, and Workflows R/W for hotfixes that touch.github/workflows/**. Absent, the PR still opens (asgithub-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
CODEOWNERSpath needs a code-owner approval the bot's cannot satisfy, and auto-merge would sit armed but never fire. The job checksreviewDecisionafter arming and comments on the PR when it is stillREVIEW_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
| Guardrail | Mechanism | Effect |
|---|---|---|
| Hotfix can't land on trunk via merge | forbid-hotfix-merge.yml (PR check, required on main) | Fails any PR whose head is hotfix/* |
| Forward-port lands without human toil | forward-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 skipped | drift-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 clean | Release 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 protected | main ruleset (cloned from the old dev ruleset's checks + forbid-hotfix-merge, squash-only, code-owner review) | Trunk integrity |
| Hotfix branches created only by automation | branch ruleset on hotfix/* | Creation restricted to the create-hotfix actor |
| State is authoritative | record-deployment → GitHub Deployments | "What's in prod?" is a query, not a guess |
Workflow inventory
| Workflow | Trigger | Role |
|---|---|---|
🛠️ Deploy Dev | push main | Build + deploy to dev; tag snapshot/<version>; record |
🧪 Promote to Staging | dispatch (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 Production | dispatch (prod approval; blank ref = staging-validated) | Mint tag on the staging-validated commit → Release → prod fan-out |
🔥 Create Hotfix Branch | dispatch | Branch off live prod SHA |
🚑 Promote Hotfix to Production | dispatch | Tag + deploy hotfix to prod, forward-port |
forward-port-hotfix | workflow_call | Cherry-pick hotfix → main PR, then approve + merge it |
forbid-hotfix-merge | pull_request → main | Block direct hotfix merges |
drift-sentinel | cron + dispatch | Detect un-forward-ported prod commits |
cleanup-snapshot-tags | cron + dispatch | Prune snapshot/* tags older than 30d |
build-and-deploy | workflow_call | Build once, fan out, record deployment |
deploy-release | release: published | Fires on a minted Release → environment-setup → build-and-deploy |
Related
- Releasing & Hotfixing — the day-to-day HOWTO.
- Migrations: expand & contract — the discipline hotfixes rely on.