Branch-Based CI/CD: Quality Gate to Multi-Environment Deploy with GitHub Actions
1. The problem this solves
A common goal: when code lands on a branch and passes the quality gate, automatically build a Docker image, scan it for vulnerabilities, and deploy it to the server that matches that branch - develop to dev, staging to staging, main to production, with production requiring a human to approve the release.
The naive approach is one deploy file per environment, each triggered independently. That works but rots fast: three files drift apart, a fix in one is forgotten in the others, and secrets get duplicated. The pattern below uses one reusable deploy workflow parameterized by environment, called from the quality-gate workflow, with per-environment secrets and protection rules living in GitHub Environments.
2. The workflow_run trap (why the obvious approach fails silently)
The instinctive way to chain "run B after A finishes" is the workflow_run trigger:
on:
workflow_run:
workflows: ["CodeQuality Checks"]
types: [completed]
branches: [develop]
This looks correct and frequently does nothing at all. Two rules cause most of the silent failures:
Rule 1: workflow_run is armed only from the default branch. GitHub reads a workflow_run trigger definition only from the copy of the file on the repository's default branch. If your default branch is, say, main (or something unexpected like a codex/* branch) and the deploy file exists only on develop, the trigger is never registered. The upstream workflow can pass a thousand times; nothing listens. The branches: filter does not change this - the file must physically exist on the default branch to be active, even though the filter then restricts it to acting on develop.
A quick way to discover your real default branch is the "This branch is N commits ahead of X" banner on the repo's Code tab - X is the default branch GitHub compares against.
Rule 2: the branches: filter matches the upstream run's head branch. It does not mean "the target branch." If the upstream (quality) workflow runs on pull_request, its head branch is the PR's source branch (e.g. feature/x), not develop, so branches: [develop] never matches. It only lines up when the upstream runs on push to develop.
Because of these two traps, and because relying on the default branch is undesirable when your default branch is not the branch you deploy from, this document avoids workflow_run entirely and uses a reusable workflow instead.
3. Why a reusable workflow (workflow_call) is the right primitive
A workflow invoked with workflow_call is resolved from the caller's ref, not the default branch. If the quality workflow runs on develop and calls the deploy workflow with uses: ./.github/workflows/deploy.yml, GitHub loads the deploy file from develop. No default-branch dependency, no head-branch filter guessing. The two workflows also appear as a single run graph with a real dependency edge, so a failed build visibly blocks the deploy.
workflow_dispatch (manual/API trigger) is not an escape hatch here: dispatching via the API has the same default-branch requirement as workflow_run. Reusable workflow_call is the only model fully independent of the default branch.
4. Architecture overview
push to develop / staging / main
β
βΌ
βββββββββββββββββββββββββββ
β Quality workflow β runs on: push [develop, staging, main]
β (SonarQube scan + gate)β
βββββββββββββ¬ββββββββββββββ
β success()
βΌ
βββββββββββββββββββββββββββ
β resolve-env job β maps branch β environment / tag / path
βββββββββββββ¬ββββββββββββββ
β outputs
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββ
β deploy.yml (reusable, workflow_call) β
β β
β dependency-check β build-and-push β β
β image-scan β deploy (Environment-gated) β
βββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
dev / staging / production server (SSH + docker compose)
Branch-to-environment mapping:
| Branch | Environment | Image tag | Deploy path | Approval |
|---|---|---|---|---|
| develop | dev | dev | /home/apps/pmis-web-dev | none |
| staging | staging | staging | /home/apps/pmis-web-staging | none |
| main | production | prod | /home/apps/pmis-web-prod | required |
5. Prerequisites: GitHub Environments
Before the workflows will work, create three Environments under Settings β Environments: dev, staging, and production.
Environments do two jobs here. First, they scope secrets: each environment holds its own copy of the deploy secrets pointing at that environmentβs server. Second, they enforce protection rules: adding required reviewers to production makes the deploy job pause and wait for a human to approve before it runs.
Inside each environment, define these secrets (same key names, different values per environment):
| Secret | Meaning |
|---|---|
SERVER_IP |
Target host for that environment |
SERVER_USER |
SSH user |
SERVER_SSH_KEY |
Private key for that user |
GHCR_TOKEN |
Token with read:packages for pulling on server |
GHCR_USERNAME |
GHCR username matching the token |
NEXT_PUBLIC_API_URL |
Public API URL baked into the build for that env |
Repository-level (or organization-level) secrets, shared across environments:
| Secret | Meaning |
|---|---|
SONAR_TOKEN |
SonarQube auth token |
SONAR_HOST_URL |
SonarQube server URL |
GITHUB_TOKEN is provided automatically; no need to create it.
Note: the key names here are env-neutral (
SERVER_IPrather thanDEV_SERVER_IP). Because each environment supplies its own value, there is no reason to prefix them withDEV_. Pick one convention and use it consistently in both the workflow files and every environment.
6. The quality-gate workflow (the caller)
This runs on every push to the three deployable branches, runs the SonarQube scan and quality gate, then - only if the gate passed and the push was to a known branch - resolves the target environment and calls the reusable deploy workflow.
name: CodeQuality Checks
on:
push:
branches:
- develop
- staging
- main
jobs:
# βββββββββββββββββββββββββββββββββββββββββββββββββββββ
# 1. SonarQube scan + quality gate
# βββββββββββββββββββββββββββββββββββββββββββββββββββββ
code-quality:
name: Code Quality
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0 # full history so Sonar can attribute blame
- name: SonarQube Scan
uses: sonarsource/sonarqube-scan-action@master
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
- name: SonarQube Quality Gate
uses: sonarsource/sonarqube-quality-gate-action@master
timeout-minutes: 5
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
# βββββββββββββββββββββββββββββββββββββββββββββββββββββ
# 2. Map the pushed branch to an environment
# βββββββββββββββββββββββββββββββββββββββββββββββββββββ
resolve-env:
name: Resolve Target Environment
needs: [code-quality]
if: >-
${{ success() && contains(fromJSON('["refs/heads/develop","refs/heads/staging","refs/heads/main"]'), github.ref) }}
runs-on: ubuntu-latest
outputs:
environment: ${{ steps.map.outputs.environment }}
image_tag: ${{ steps.map.outputs.image_tag }}
deploy_path: ${{ steps.map.outputs.deploy_path }}
steps:
- name: Map branch to environment
id: map
run: |
case "${{ github.ref }}" in
refs/heads/develop)
echo "environment=dev" >> $GITHUB_OUTPUT
echo "image_tag=dev" >> $GITHUB_OUTPUT
echo "deploy_path=/home/apps/pmis-web-dev" >> $GITHUB_OUTPUT
;;
refs/heads/staging)
echo "environment=staging" >> $GITHUB_OUTPUT
echo "image_tag=staging" >> $GITHUB_OUTPUT
echo "deploy_path=/home/apps/pmis-web-staging" >> $GITHUB_OUTPUT
;;
refs/heads/main)
echo "environment=production" >> $GITHUB_OUTPUT
echo "image_tag=prod" >> $GITHUB_OUTPUT
echo "deploy_path=/home/apps/pmis-web-prod" >> $GITHUB_OUTPUT
;;
esac
# βββββββββββββββββββββββββββββββββββββββββββββββββββββ
# 3. Call the reusable deploy workflow
# βββββββββββββββββββββββββββββββββββββββββββββββββββββ
deploy:
name: Deploy
needs: resolve-env
permissions:
contents: read
packages: write
uses: ./.github/workflows/deploy.yml
with:
environment: ${{ needs.resolve-env.outputs.environment }}
image_tag: ${{ needs.resolve-env.outputs.image_tag }}
deploy_path: ${{ needs.resolve-env.outputs.deploy_path }}
head_sha: ${{ github.sha }}
secrets: inherit
Notes on the caller:
success()in theresolve-envguard already means every job inneedspassed, so a separate "conclusion == success" check is redundant.- The
contains(fromJSON(...))check ensures the pipeline only fires on the three intended branches and skips cleanly on anything else. secrets: inheritforwards all repository and the resolved environment's secrets into the called workflow - without it the reusable workflow sees no secrets at all.- Passing
head_sha: ${{ github.sha }}and having the reusable workflow check that exact commit out guarantees you build precisely what Sonar validated, not whatever happens to be at the branch tip when the deploy job starts.
7. The reusable deploy workflow (the engine)
Four jobs run in sequence: audit dependencies, build and push the image, scan the pushed image, then deploy over SSH. The deploy job is bound to the resolved Environment, which is what activates per-environment secrets and the production approval gate.
name: Deploy PMIS Web
on:
workflow_call:
inputs:
environment:
description: "Target environment (dev | staging | production)"
required: true
type: string
image_tag:
description: "Image tag to build, push, and deploy"
required: true
type: string
deploy_path:
description: "Absolute path to the compose project on the server"
required: true
type: string
head_sha:
description: "Commit SHA to build and deploy"
required: true
type: string
audit_level:
description: "npm audit failure threshold"
required: false
type: string
default: high
env:
REGISTRY: ghcr.io
concurrency:
group: deploy-pmis-web-${{ inputs.environment }}
cancel-in-progress: false
jobs:
# βββββββββββββββββββββββββββββββββββββββββββββββββββββ
# 1. Dependency vulnerability check
# βββββββββββββββββββββββββββββββββββββββββββββββββββββ
dependency-check:
name: Dependency Vulnerability Check
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.head_sha }}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Audit dependencies
run: npm audit --audit-level=${{ inputs.audit_level }}
# βββββββββββββββββββββββββββββββββββββββββββββββββββββ
# 2. Build the image in CI and push to GHCR
# βββββββββββββββββββββββββββββββββββββββββββββββββββββ
build-and-push:
name: Build & Push Image
needs: dependency-check
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.image-name.outputs.image }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.head_sha }}
- name: Set lowercase image name
id: image-name
run: |
echo "image=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker image metadata
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ steps.image-name.outputs.image }}
tags: |
type=sha,prefix=sha-
type=raw,value=${{ inputs.image_tag }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
# βββββββββββββββββββββββββββββββββββββββββββββββββββββ
# 3. Scan the pushed image for OS / package CVEs
# βββββββββββββββββββββββββββββββββββββββββββββββββββββ
image-scan:
name: Container Security Scan
needs: build-and-push
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
packages: read
steps:
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ${{ needs.build-and-push.outputs.image }}:${{ inputs.image_tag }}
severity: CRITICAL,HIGH
exit-code: '1'
ignore-unfixed: true
# βββββββββββββββββββββββββββββββββββββββββββββββββββββ
# 4. Pull the pre-built image on the server and restart
# βββββββββββββββββββββββββββββββββββββββββββββββββββββ
deploy:
name: Deploy to Server
needs: [build-and-push, image-scan]
runs-on: ubuntu-latest
timeout-minutes: 10
environment: ${{ inputs.environment }} # activates env secrets + approval gate
steps:
- name: Deploy to Server via SSH
uses: appleboy/ssh-action@v1.2.5
env:
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
IMAGE: ${{ needs.build-and-push.outputs.image }}
IMAGE_TAG: ${{ inputs.image_tag }}
DEPLOY_PATH: ${{ inputs.deploy_path }}
with:
host: ${{ secrets.SERVER_IP }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
port: 22
envs: GHCR_TOKEN,IMAGE,IMAGE_TAG,DEPLOY_PATH
script: |
echo "$GHCR_TOKEN" | docker login ghcr.io -u ${{ secrets.GHCR_USERNAME }} --password-stdin
docker pull "$IMAGE:$IMAGE_TAG"
cd "$DEPLOY_PATH"
docker compose -f docker-compose.yml down --remove-orphans
docker compose -f docker-compose.yml up -d
docker image prune -f
8. Design decisions worth understanding
Build once, deploy the artifact.
Comments
No comments yet. Start the discussion.