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.pywith JUnit XML output - Publishes test results as a check via
dorny/test-reporter - Does not require a running server (uses FastAPI's
TestClientwith mocked SMTP)
Duration: ~1–2 minutes
5. deploy-application.yml — Production Application Deployment¶
Triggers:
workflow_runafterdeploy-infrastructure.ymlsucceeds onmain→ 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
customDomainVerificationIdand frontend FQDN viaaz 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→ createsdna-shared-portfolio-westeurope-rgwith: - 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
- TXT
- Azure App Service Domain purchase (opt-in via
purchase_domain: trueinparameters.shared.bicepparam) - When
purchase_domain: true: fetches legal agreement keys for.comTLD, 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.ymlonmainfirst) - 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_domainin 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:
- Stage 1 — No binding (initial infra deploy,
custom_domainempty ordeploy_custom_domain=false) - Stage 2 —
Disabledbinding: hostname registered in CAE, no TLS yet (custom_domainset,deploy_custom_domain=false) - Stage 3 —
SniEnabled+ 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:
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.