Skip to content

GitHub Actions Workflows

This platform uses GitHub Actions for all CI/CD. There are eight workflows in total — seven site deployment workflows and one infrastructure workflow. All workflow files live in .github/workflows/.

Every workflow in this repository follows a consistent set of conventions:

  • Path-filtered triggers. Each workflow only runs when files in its relevant directory change on main, avoiding unnecessary builds.
  • Pinned action versions. All third-party actions are pinned to full commit SHAs rather than tags, preventing supply-chain attacks from tag mutation.
  • OIDC authentication. Azure credentials are never stored as secrets. GitHub Actions authenticates via OpenID Connect federated identity, configured in the github-oidc Terraform module.
  • Concurrency control. Each deploy workflow uses a concurrency group with cancel-in-progress: false, ensuring in-flight deployments complete before the next one starts.
  • Manual dispatch. All deploy workflows support workflow_dispatch for manual reruns without requiring a code change.

All seven sites share an identical deployment pattern. The only differences between them are the path filter, Dockerfile location, image name, and manifest path.

graph TD
    A[Push to main<br/>path filter matches] --> B[Checkout + compute short SHA]
    B --> C[Log in to GHCR]
    C --> D[Log in to Azure via OIDC]
    D --> E[Log in to ACR]
    E --> F[Docker Buildx build + push]
    F --> G[Update K8s deployment manifest<br/>with new image tag]
    G --> H[Commit + push manifest<br/>with retry loop]

The repository is checked out and the short commit SHA is captured. This SHA becomes the Docker image tag, providing a direct link between every running container and the commit that produced it.

Three logins happen in sequence:

  • GHCR — using the built-in GITHUB_TOKEN
  • Azure — via OIDC (azure/login with client-id, tenant-id, subscription-id)
  • ACR — using the Azure CLI session established in the previous step

Each image is built with Docker Buildx (enabling build cache via GitHub Actions cache) and pushed with four tags:

TagRegistryPurpose
<sha>GHCRImmutable version reference
latestGHCRConvenience for local development
<sha>ACRProduction deployment (K8s pulls from here)
latestACRRollback convenience

The COMMIT_SHA build arg is passed so the application can embed its version at build time.

The workflow uses sed to replace the image tag in k8s/<site>/deployment.yaml with the new ACR-tagged image. This is the GitOps trigger — when Flux CD sees this change, it reconciles the cluster.

The manifest change is committed as [deploy] <site>: <sha> and pushed to main. A retry loop (5 attempts with exponential backoff) handles race conditions when multiple site workflows push concurrently. Each attempt does a git pull --rebase before pushing.

WorkflowFileTrigger PathSite
Build and Deploydeploy.ymlsites/kevinryan-io/**kevinryan.io
Build and Deploy Branddeploy-brand.ymlsites/brand-kevinryan-io/**brand.kevinryan.io
Build and Deploy Docsdeploy-docs.ymlsites/docs-kevinryan-io/** or docs/**docs.kevinryan.io
Build and Deploy AI-Immigrantsdeploy-aiimmigrants.ymlsites/aiimmigrants-com/**aiimmigrants.com
Build and Deploy SpecMCPdeploy-specmcp.ymlsites/specmcp-ai/**specmcp.ai
Build and Deploy SDD Bookdeploy-sddbook.ymlsites/sddbook-com/**sddbook.com
Build and Deploy Distributed Equitydeploy-distributedequity.ymlsites/distributedequity-org/**distributedequity.org

The docs workflow is the only one with two trigger paths — changes to either the site source (sites/docs-kevinryan-io/**) or the shared documentation content (docs/**) will trigger a rebuild, since the docs site symlinks content from the docs/ directory.

Every deploy workflow requests three permission scopes:

PermissionReason
contents: writeCommit the updated K8s manifest back to main
packages: writePush Docker images to GHCR
id-token: writeRequest an OIDC token for Azure authentication

The infrastructure workflow (terraform.yml) manages all Azure and Cloudflare resources. It follows a plan/approve/apply pattern with environment protection.

graph TD
    A[Push to main<br/>infra/** changed] --> B[Terraform Plan]
    B --> C[Post plan to job summary]
    C --> D[Upload plan artifact]
    D --> E{Manual approval<br/>production environment}
    E -->|Approved| F[Download plan artifact]
    F --> G[Terraform Apply]

Triggered on any push to main that changes files under infra/:

  1. Checkout the repository
  2. Set up Terraform CLI
  3. Authenticate to Azure via OIDC
  4. Run terraform init and terraform plan -out=tfplan
  5. Post the plan output to the GitHub Actions job summary for review
  6. Upload the plan file as an artifact for the apply job

Runs only after the plan job completes and a reviewer approves in the production GitHub environment:

  1. Checkout the repository
  2. Set up Terraform and authenticate to Azure
  3. Download the plan artifact from the plan job
  4. Run terraform apply tfplan using the exact plan that was reviewed

This two-stage approach ensures no infrastructure changes are applied without human review, while still keeping the plan deterministic — the same plan file produced during review is the one applied.

PermissionReason
contents: readRead the Terraform configuration
id-token: writeRequest an OIDC token for Azure authentication

Note that the Terraform workflow only needs contents: read (not write) since it does not commit anything back to the repository.

The Terraform workflow passes several secrets as environment variables:

VariableSource
ARM_CLIENT_ID / ARM_TENANT_ID / ARM_SUBSCRIPTION_IDAzure OIDC identity
TF_VAR_cloudflare_api_tokenCloudflare API access
TF_VAR_admin_ssh_public_keySSH key for VM access
TF_VAR_admin_ipIP allowlist for NSG rules
TF_VAR_cloudflare_zone_idCloudflare DNS zone
TF_VAR_acr_nameAzure Container Registry name
TF_VAR_github_tokenFlux CD GitHub access
  • No long-lived credentials. Azure authentication uses OIDC federated identity throughout. No client secrets are stored in GitHub.
  • Pinned actions. Every uses: reference is pinned to a full commit SHA with a version comment, preventing compromised tags from injecting malicious code.
  • Least privilege. Each workflow requests only the permissions it needs. Deploy workflows need write access; Terraform only needs read.
  • Environment protection. Terraform apply requires manual approval via GitHub’s production environment, preventing accidental infrastructure changes.
  • Concurrency groups. Prevent parallel deployments to the same site from creating race conditions in the cluster.

To add a deployment workflow for a new site:

  1. Copy any existing deploy-*.yml file
  2. Update the name, paths filter, concurrency group, Dockerfile file path, image name in tags, and the sed command to target the correct manifest
  3. Ensure the site has a Dockerfile and corresponding k8s/<site>/deployment.yaml
  4. The new workflow will trigger on the next push to main that changes files in the site’s directory