DEV Community

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_IP rather than DEV_SERVER_IP). Because each environment supplies its own value, there is no reason to prefix them with DEV_. 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 the resolve-env guard already means every job in needs passed, 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: inherit forwards 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.