Skip to content

GitHub Actions Workflow Execution Order

Last Updated: March 2026


Overview

Eight GitHub Actions workflows handle the full lifecycle of the portfolio project. Infrastructure and application deployments are separated so role assignments can propagate in Azure before containers start.


Workflows

1. validate-infrastructure.yml — Bicep Validation (PR only)

Triggers: PR to main touching infra/** or the workflow file itself; workflow_dispatch

Job structure:

Job Runs when What it does
detect-changes Always Uses dorny/paths-filter to classify changed files into main_modules, dev_params, prod_params, shared
validate Always Lint → conditional validation → conditional what-if → security scan → PR comment

Validation steps (conditional):

Step Condition What it does
Bicep Lint Always az bicep build on main.bicep + shared.bicep; az bicep build-params on all three .bicepparam files
Validate (Dev) main_modules or dev_params changed ARM template validation with parameters.dev.bicepparam
Validate (Prod) main_modules or prod_params changed ARM template validation with parameters.prod.bicepparam
Validate (Shared) shared changed ARM template validation with parameters.shared.bicepparam
Validate (Shared — first-deploy) shared changed Same but with empty ca_env_verification_id + frontend_fqdn
What-If (Dev) main_modules or dev_params changed Preview of changes to dev environment
What-If (Prod) main_modules or prod_params changed Preview of changes to production
What-If (Shared) shared changed Preview of changes to shared infrastructure
Security Scan Always Checkov IaC scan (Bicep framework); SARIF uploaded to GitHub Security tab

Duration: ~2–4 minutes (full), ~1–2 minutes (single-environment change)


2. deploy-infrastructure.yml — Infrastructure Deployment

Triggers:

Event Environment Containers
PR to main dev No
Push to main prod No
Git tag v* prod No
Manual dispatch choice choice

What it does:

  • Creates/updates all Azure resources (resource group, ACR, Key Vault, Container Apps Environment, Log Analytics, App Insights)
  • Always creates managed identities and ACR/Key Vault role assignments — even without containers — so roles propagate before the next run
  • Posts deployment summary as PR comment

Duration: ~5–10 minutes


3. deploy-pr-preview.yml — PR Preview

Triggers: PR to main (path-filtered per job — see below)

Job structure:

Job Runs when What it does
detect-changes Always Runs dorny/paths-filter to detect frontend/backend/docs changes
deploy-app client/**, api/**, or related files changed Builds only changed images (falls back to rebuild if ACR tag absent), deploys Bicep with deploy_containers=true, runs smoke tests
deploy-docs docs/** or mkdocs.yml changed Builds MkDocs and deploys to dev Static Web App
post-comment At least one job ran Posts/updates PR comment with per-service status and URLs

Notes:

  • Dev deployments are serialised via a concurrency group (deploy-dev-app). Only one PR deploys at a time; additional runs queue. A newer push to the same PR replaces any pending (queued) run — the running deploy is never interrupted.
  • If only docs change, containers are never built or deployed
  • If only backend changes, frontend image is reused from ACR (rebuilt only on first PR push)
  • Requires dev infrastructure to exist first (deploy it manually or via an infra-touching PR)
  • Does not clean up on PR close (shared environment persists)

Duration: ~5–8 min (app+docs), ~2–3 min (docs only), ~3–5 min (one service only)


4. test-api.yml — API Unit Tests

Triggers: PR to main touching api/**, tests/**, or the workflow file itself

What it does:

  • Installs Python dependencies from api/requirements.txt
  • Runs pytest tests/test_api_endpoints.py with JUnit XML output
  • Publishes test results as a check via dorny/test-reporter
  • Does not require a running server (uses FastAPI's TestClient with mocked SMTP)

Duration: ~1–2 minutes


5. deploy-application.yml — Production Application Deployment

Triggers:

  • workflow_run after deploy-infrastructure.yml succeeds on main → deploys to prod
  • Manual workflow_dispatch → choose environment

What it does:

  • Builds frontend and backend Docker images, tagged with commit SHA and latest
  • Pushes images to the prod ACR
  • Deploys Container Apps to prod with deploy_containers=true
  • Runs smoke tests against both URLs

Duration: ~5–8 minutes


6. deploy-docs-github-pages.yml — MkDocs to GitHub Pages

Triggers: Push to main touching docs/** or mkdocs.yml

What it does: Builds and deploys MkDocs site to GitHub Pages.

URL: https://svenrelijveld1995.github.io/portfolio/


7. deploy-docs.yml — MkDocs to Azure Static Web App

Triggers: workflow_run after deploy-infrastructure.yml or deploy-application.yml succeeds on main

What it does: Builds and deploys MkDocs site to the Azure Static Web App using a deployment token from Key Vault.


8. deploy-infrastructure-shared.yml — Shared Infrastructure (DNS + Domain)

Triggers: Push to main touching infra/shared.bicep, infra/modules/dns_zone.bicep, infra/modules/app_service_domain.bicep, infra/parameters.shared.bicepparam, or the workflow file

What it does:

  • Discover Prod Values — queries prod CAE customDomainVerificationId and frontend FQDN via az rest (ARM API)
  • Discover Dev + SWA Values — queries dev CAE verification ID, dev frontend FQDN, prod SWA default hostname, and dev SWA default hostname at runtime
  • Deploys shared.bicep → creates dna-shared-portfolio-westeurope-rg with:
  • Azure DNS zone for the custom domain
  • All DNS records — each conditional on the discovered value being non-empty:
    • TXT asuid + asuid.www → prod CA env verification ID
    • TXT asuid.dev → dev CA env verification ID
    • CNAME www → prod frontend Container App FQDN
    • CNAME dev → dev frontend Container App FQDN
    • CNAME docs → prod SWA default hostname
    • CNAME dev-docs → dev SWA default hostname
  • Azure App Service Domain purchase (opt-in via purchase_domain: true in parameters.shared.bicepparam)
  • When purchase_domain: true: fetches legal agreement keys for .com TLD, determines runner public IP, validates domain availability, then passes all consent data to Bicep at runtime
  • Sensitive contact fields (CONTACT_PHONE, CONTACT_ADDRESS1, CONTACT_CITY, CONTACT_STATE, CONTACT_POSTAL_CODE) are injected from GitHub Secrets — not stored in the parameter file

Notes:

  • Runs independently of dev/prod infrastructure — DNS zones are environment-agnostic
  • Requires prod Container Apps to exist first (run deploy-infrastructure.yml on main first)
  • Dev and SWA discovery steps emit empty strings gracefully when the target resources don't yet exist — no hard failures
  • When domain is purchased via Azure App Service Domain, NS delegation is automatic — no registrar changes needed
  • Custom domain activation requires this workflow to run first so DNS records exist, then flip deploy_custom_domain / deploy_swa_custom_domain in parameters

Duration: ~3–5 minutes


Execution Scenarios

Scenario A: PR from Feature Branch

1. Developer opens PR to main
   ├── validate-infrastructure.yml  (if infra/** changed)
   │   ├── detect-changes  (detects: main_modules / dev_params / prod_params / shared)
   │   ├── Lint + validate only the affected templates
   │   ├── What-if only for the affected environments (dev / prod / shared)
   │   ├── Security scan (Checkov) → SARIF to GitHub Security tab
   │   └── Posts validation summary to PR
   ├── test-api.yml  (if api/** or tests/** changed)
   │   └── Runs pytest, publishes test results check
   └── deploy-pr-preview.yml
       ├── detect-changes  (always)
       │   └── Detects which of: frontend / backend / docs changed
       ├── deploy-app  (if frontend or backend files changed)
       │   ├── Builds only changed images (reuses ACR tag if unchanged)
       │   ├── Deploys containers to dev
       │   └── Smoke tests
       ├── deploy-docs  (if docs/** or mkdocs.yml changed)
       │   └── Builds MkDocs → deploys to dev SWA
       └── post-comment  (if any job ran)
           └── Posts/updates PR comment with per-service status + URLs

Total: ~10–15 min (full), ~1–2 min (API tests only), ~2–3 min (docs only)


Scenario B: Merge to Main (Production)

1. PR merged to main
   └── deploy-infrastructure.yml
       └── Deploys infra to prod (no containers)
           └── deploy-application.yml (workflow_run trigger)
               └── Builds images → pushes to prod ACR → deploys containers to prod
                   └── Runs smoke tests
                       └── deploy-docs.yml (workflow_run trigger)
                           └── Deploys MkDocs to Azure SWA

Total: ~15–20 minutes


Scenario C: Documentation-only Change

1. Push to main touching docs/** or mkdocs.yml
   └── deploy-docs-github-pages.yml
       └── Builds and deploys MkDocs to GitHub Pages

Total: ~2–3 minutes


Scenario D: Shared Infrastructure / Custom Domain Change

1. Push to main touching infra/shared.bicep or infra/modules/dns_zone.bicep
   └── deploy-infrastructure-shared.yml
       ├── Discovers prod CA env verification ID + frontend FQDN (az rest)
       ├── Discovers dev CA env VID + dev FE FQDN + prod/dev SWA hostnames
       ├── If purchase_domain=true: fetches TLD agreement keys + runner IP
       ├── Validates domain availability (pre-check)
       └── Deploys shared.bicep to dna-shared-portfolio-westeurope-rg
           ├── DNS zone
           ├── TXT: asuid, asuid.www → prod CA env verification ID
           ├── TXT: asuid.dev        → dev CA env verification ID
           ├── CNAME: www            → prod frontend Container App FQDN
           ├── CNAME: dev            → dev frontend Container App FQDN
           ├── CNAME: docs           → prod SWA default hostname
           ├── CNAME: dev-docs       → dev SWA default hostname
           └── App Service Domain purchase (when purchase_domain=true)
               └── NS delegation automatic — no registrar changes needed

Total: ~3–5 minutes


Environment Mapping

GitHub Environment Azure Resources Used By
dev dna-dev-portfolio-westeurope-rg PR preview, infra PRs
prod dna-prod-portfolio-westeurope-rg Main branch pushes
shared dna-shared-portfolio-westeurope-rg DNS zone / custom domain push

Deployed URLs (prod)

  • Frontend: https://www.sven-relijveld.com (custom domain, managed TLS cert)
  • Frontend (direct): https://dna-prd-portfolio-we-ca-fe.politepebble-2dcbf46f.westeurope.azurecontainerapps.io
  • Backend: https://dna-prd-portfolio-we-ca-be.politepebble-2dcbf46f.westeurope.azurecontainerapps.io
  • Docs (GitHub Pages): https://svenrelijveld1995.github.io/portfolio/
  • Docs (Azure SWA): https://docs.sven-relijveld.com (custom domain, cname-delegation)

Deployed URLs (dev)

  • Frontend: https://dev.sven-relijveld.com (custom domain, managed TLS cert)
  • Docs (Azure SWA): https://dev-docs.sven-relijveld.com (custom domain, cname-delegation)

Key Design Decisions

Managed Identities Are Always Deployed

Managed identities and RBAC role assignments in container_apps.bicep are unconditional — they deploy even when deploy_containers=false. This ensures roles propagate in Azure AD before the subsequent run that actually creates the Container Apps.

deploy_containers=false in the Infrastructure Workflow

deploy-infrastructure.yml always passes deploy_containers=false. Container deployment is handled separately by deploy-application.yml (prod) and deploy-pr-preview.yml (dev). This prevents race conditions between image availability and ARM deployments.

PR Preview Uses the Shared Dev Environment

Rather than ephemeral per-PR environments, the PR preview deploys to the single shared dev environment. This keeps costs low and avoids lengthy teardown/cleanup logic.

Custom Domain Binding Is a Three-Stage Process

Azure Container Apps requires the hostname to be registered in the environment before a managed certificate can be issued:

  1. Stage 1 — No binding (initial infra deploy, custom_domain empty or deploy_custom_domain=false)
  2. Stage 2Disabled binding: hostname registered in CAE, no TLS yet (custom_domain set, deploy_custom_domain=false)
  3. Stage 3SniEnabled + managed cert: certificate issued and attached (deploy_custom_domain=true)

The container_apps.bicep module uses a 3-way ternary to select the correct binding state. Both parameters.prod.bicepparam and parameters.dev.bicepparam now have deploy_custom_domain: true (Stage 3, SniEnabled). The deploy-infrastructure.yml env-only step always overrides deploy_custom_domain=false so managed cert creation is never attempted during base infrastructure deploys (when frontend_app doesn't exist).

Sensitive Contact Fields Come from GitHub Secrets

parameters.shared.bicepparam stores only non-sensitive registrant fields (name, email, country). Phone number, address, city, state, and postal code are injected at workflow runtime from CONTACT_PHONE, CONTACT_ADDRESS1, CONTACT_CITY, CONTACT_STATE, and CONTACT_POSTAL_CODE GitHub Secrets. This keeps PII out of git history.


Troubleshooting

PR preview fails: "Container Registry not found"

Dev infrastructure hasn't been deployed yet. Run the infrastructure workflow manually for dev:

gh workflow run deploy-infrastructure.yml --field environment=dev --field deploy_containers=false

Application deploys to wrong environment

The workflow_run trigger in deploy-application.yml only fires when infra runs on main. It reads ${{ github.ref }} to determine prod vs dev — verify the ref is refs/heads/main for prod deploys.

OIDC authentication fails

# Verify federated credentials exist for the right subject
az ad app federated-credential list --id <AZURE_CLIENT_ID>

Expected subjects: repo:SvenRelijveld1995/portfolio:ref:refs/heads/main, repo:SvenRelijveld1995/portfolio:pull_request, repo:SvenRelijveld1995/portfolio:environment:prod, repo:SvenRelijveld1995/portfolio:environment:dev.