Tuesday, 16 December 2025

Designing a Production-Grade, End-to-End Deployment Workflow in GitHub Actions (Build → Deploy → E2E → Slot Swap)

 An end-to-end deployment workflow should do more than “deploy on push.” A reliable pipeline builds the application deterministically, deploys safely (often to a staging slot), verifies with automated tests, and promotes via a controlled release step (like a slot swap) only when quality gates pass.

What the example workflow does (high-level)

Best practices demonstrated (and how to apply them)

1) Use path filters to avoid unnecessary runs

2) Provide a manual “release console” with workflow_dispatch inputs

3) Make “staging slot first” the default release strategy

4) Separate Build and Deploy jobs with clear boundaries

5) Centralize secrets, but keep the pipeline in control

6) Use reusable actions and reusable workflows for consistency

7) Gate promotion using conditional logic + job dependencies

A generic reference workflow (cleaned up example)

name: Prod - Web App Deployment

on:
push:
branches: [ main ]
paths:
- "UI/**"
workflow_dispatch:
inputs:
slot:
description: "Target slot"
required: false
default: "staging"
type: choice
options: [ staging, production ]
runUiE2E:
description: "Run UI E2E after deployment"
required: false
default: "true"
type: choice
options: [ "true", "false" ]
runMobileE2E:
description: "Run Mobile E2E after deployment"
required: false
default: "true"
type: choice
options: [ "true", "false" ]

env:
SLOT_NAME: ${{ inputs.slot || 'staging' }}

jobs:
build:
runs-on: windows-latest
environment: Production
steps:
- uses: actions/checkout@v4

- name: Cloud login (OIDC recommended)
uses: cloud/login@v2
with:
# prefer OIDC or short-lived credentials
creds: ${{ secrets.CLOUD_CREDENTIALS }}

- name: Load secrets
uses: cloud/get-secrets@v1
with:
vault: ${{ secrets.SECRETS_VAULT_NAME }}
secrets: "APP_CLIENT_ID,APP_TENANT_ID,OBSERVABILITY_CONN_STRING"
id: secrets

- name: Build web
uses: ./.github/actions/build_web
with:
clientId: ${{ steps.secrets.outputs.APP_CLIENT_ID }}
tenantId: ${{ steps.secrets.outputs.APP_TENANT_ID }}
observability: ${{ steps.secrets.outputs.OBSERVABILITY_CONN_STRING }}
skipTestRun: "false"

deploy:
runs-on: windows-latest
needs: build
environment:
name: Production
url: ${{ steps.deploy.outputs.webapp-url }}
steps:
- uses: actions/checkout@v4

- name: Cloud login
uses: cloud/login@v2
with:
creds: ${{ secrets.CLOUD_CREDENTIALS }}

- name: Deploy to slot
id: deploy
uses: ./.github/actions/deploy_web
with:
appName: ${{ secrets.WEB_APP_NAME }}
slotName: ${{ env.SLOT_NAME }}
resourceGroup: ${{ vars.RESOURCE_GROUP }}

ui_e2e:
needs: deploy
if: ${{ github.event_name != 'workflow_dispatch' || inputs.runUiE2E == 'true' }}
uses: ./.github/workflows/ui_e2e_tests.yml
with:
environment: Production
useStagingUrl: true
secrets: inherit

mobile_e2e:
needs: deploy
if: ${{ github.event_name != 'workflow_dispatch' || inputs.runMobileE2E == 'true' }}
uses: ./.github/workflows/mobile_e2e_tests.yml
with:
environment: Production
useStagingUrl: true
secrets: inherit

swap_slot:
needs: [ui_e2e, mobile_e2e]
if: >
${{
(github.event_name != 'workflow_dispatch' ||
(inputs.runUiE2E == 'true' && inputs.runMobileE2E == 'true'))
&& needs.ui_e2e.result == 'success'
&& needs.mobile_e2e.result == 'success'
}}
uses: ./.github/workflows/slot_swap.yml
with:
environment: Production
slot: ${{ env.SLOT_NAME }}
secrets: inherit

Production-hardening checklist (what I would add next)

Closing thoughts

No comments:

Post a Comment