> **A practical, in-depth reference covering architecture, security, reusable workflows, enterprise governance, GitOps integration, and the platform engineering perspective — with real-world YAML examples throughout.**
---
## Executive Summary
GitHub Actions is a native CI/CD and workflow automation platform built directly into GitHub. Launched in 2018 and reaching general availability in November 2019, it has rapidly become one of the most widely adopted CI/CD systems in the world — GitHub reports that more than 10 million repositories use it actively.
### Why GitHub Actions Became Popular
Three structural advantages explain its adoption curve:
**Zero-friction integration.** Actions lives inside the repository. There is no separate CI server to provision, no webhook to configure, no OAuth app to authorize. A single YAML file in `.github/workflows/` is enough to start. This reduces the "time to first green build" from hours (Jenkins, TeamCity) to minutes.
**The Marketplace.** GitHub Marketplace hosts more than 20,000 community and vendor-published Actions. Setting up Docker builds, Terraform plans, SLSA provenance, Slack notifications, or AWS credential exchange requires three lines of YAML, not a plugin installation and server restart.
**Event-driven breadth.** GitHub Actions can react to more than 35 event types — not just push and pull requests, but issue comments, project board movements, release publications, external `repository_dispatch` calls, and scheduled cron jobs. This makes it a general automation platform, not just a test runner.
### How It Compares to Traditional CI/CD
|Dimension|Jenkins / TeamCity|GitHub Actions|
|---|---|---|
|Infrastructure|Self-managed server|Fully managed (hosted) or self-hosted|
|Configuration|Jenkinsfile / Kotlin DSL|YAML (declarative)|
|Plugin ecosystem|1,800+ plugins|20,000+ Marketplace Actions|
|GitHub integration|Via plugin|Native|
|Secret management|Credentials plugin|Built-in, with Environment scoping|
|Supply chain security|Manual|OIDC, SLSA, Sigstore built-in|
|Learning curve|High|Low-to-medium|
|Cost model|Infrastructure cost|Per-minute (free tier included)|
The shift is architectural: traditional CI systems are servers that poll or receive webhooks from source control. GitHub Actions inverts this — the platform is the source control system, and automation is a first-class citizen of the repository.
---
## Core Concepts
Understanding GitHub Actions requires internalizing a hierarchy of seven objects.
### Architecture Overview
```
GitHub Repository
└── Workflow (.github/workflows/*.yml)
├── Trigger (on: push, pull_request, schedule…)
└── Job (runs on a Runner)
├── Service Containers (optional)
└── Step
├── uses: owner/action@version (Action from Marketplace or local)
└── run: shell command
```
### Workflows
A **workflow** is an automated process defined in a YAML file stored under `.github/workflows/`. One repository can have multiple workflow files. Each file is independent — they share repository secrets and variables but do not share state unless you explicitly pass artifacts or outputs.
Workflow files are version-controlled alongside application code. This means CI configuration goes through code review, has a full audit trail, and can be rolled back with `git revert`.
### Events (Triggers)
An **event** is what causes a workflow to run. GitHub supports more than 35 event types:
- **Repository events**: `push`, `pull_request`, `pull_request_review`, `create`, `delete`, `release`, `fork`, `star`
- **Issue events**: `issues`, `issue_comment`, `label`, `milestone`
- **Scheduled**: `schedule` (cron syntax, UTC)
- **Manual**: `workflow_dispatch` (UI button + API + CLI)
- **External**: `repository_dispatch` (HTTP POST from any system)
- **Cross-workflow**: `workflow_call` (called by another workflow)
- **Deployment**: `deployment`, `deployment_status`
- **Security**: `security_advisory`, `code_scanning_alert`
### Jobs
A **job** is a group of steps that run sequentially on the same runner. Jobs within a workflow run in parallel by default. Use `needs:` to create sequential dependencies between jobs.
Jobs are isolated — each job gets a fresh runner environment. To pass data between jobs, use `outputs:` (for small strings) or `actions/upload-artifact` + `actions/download-artifact` (for files).
### Steps
A **step** is a single task within a job. Steps execute in sequence and share the runner's filesystem and environment variables within the job. A step is either:
- `uses:` — invokes a published or local Action
- `run:` — executes a shell command
### Runners
A **runner** is the compute that executes a job. GitHub provides managed runners (`ubuntu-latest`, `windows-latest`, `macos-latest`). Organizations can add **self-hosted runners** for custom hardware, private networking, or cost optimization.
### Actions
An **Action** is a reusable unit of automation. Actions are packaged as:
- **JavaScript/TypeScript Actions** — run directly on the runner with Node.js
- **Docker Container Actions** — run in a specified container image
- **Composite Actions** — a sequence of steps bundled into a single `action.yml`
Actions are referenced by `owner/repo@ref`. The `ref` can be a tag (`@v4`), a branch (`@main`), or a full commit SHA (`@abc1234`).
### Marketplace
The GitHub Marketplace is a directory of published Actions. Notable categories include language setup (`actions/setup-node`, `actions/setup-python`), container tools (`docker/build-push-action`), cloud credentials (`aws-actions/configure-aws-credentials`), security scanning (`aquasecurity/trivy-action`), and notification (`slackapi/slack-github-action`).
### Artifacts
**Artifacts** are files produced during a workflow run that you want to retain or share between jobs. `actions/upload-artifact` saves files to GitHub's artifact storage (retained for 90 days by default). `actions/download-artifact` retrieves them in a later job.
Common uses: compiled binaries, test reports, coverage files, Docker layer caches, Terraform plan files.
### Secrets
**Secrets** are encrypted variables stored at repository, environment, or organization level. They are masked in logs and never exposed to workflow code as plaintext — they are injected as environment variables. Secrets are accessible via `${{ secrets.SECRET_NAME }}`.
Secret scoping:
- **Repository secrets** — available to all workflows in the repo
- **Environment secrets** — available only when a job targets a named Environment
- **Organization secrets** — shareable across repositories with policy controls
### Environments
**Environments** are named deployment targets (e.g., `staging`, `production`). They enable:
- **Required reviewers** — a human must approve before the job runs
- **Wait timers** — enforced delay before execution
- **Deployment branch rules** — only specified branches can deploy here
- **Environment-scoped secrets and variables** — credentials isolated per target
Environments create an auditable deployment history visible in the GitHub UI under the "Deployments" tab.
---
## Workflow Syntax Deep Dive
### Basic YAML Structure
```yaml
name: CI # Display name in GitHub UI
on: # Triggers
push:
branches: [main]
pull_request:
branches: [main]
permissions: # Least-privilege token permissions
contents: read
env: # Workflow-level environment variables
NODE_VERSION: '20'
jobs:
build: # Job ID
name: Build and Test # Display name
runs-on: ubuntu-latest # Runner
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm test
```
### workflow_dispatch — Manual Triggers with Inputs
```yaml
on:
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
type: choice
options: [dev, staging, production]
version:
description: 'Image tag to deploy'
required: true
type: string
dry_run:
description: 'Skip actual deployment'
type: boolean
default: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy
if: inputs.dry_run == false
run: |
echo "Deploying ${{ inputs.version }} to ${{ inputs.environment }}"
```
`workflow_dispatch` can also be triggered via the GitHub CLI (`gh workflow run`) or the REST API, enabling external orchestration.
### push — Branch and Path Filtering
```yaml
on:
push:
branches:
- main
- 'release/**'
- '!release/legacy' # negation supported
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
paths:
- 'src/**'
- 'package.json'
- '!**/*.md' # ignore markdown changes
```
`paths-ignore` and `paths` are mutually exclusive. Use `paths` when you want to _include_ only specific file changes.
### pull_request — PR Lifecycle Events
```yaml
on:
pull_request:
types:
- opened
- synchronize # new commits pushed to the PR branch
- reopened
- ready_for_review
branches:
- main
```
> **Security note**: For PRs from forks, `pull_request` workflows run without access to secrets. Use `pull_request_target` with extreme caution — it runs in the context of the base branch and _does_ have access to secrets, creating a privilege escalation risk if you checkout and execute fork code.
### schedule — Cron Jobs
```yaml
on:
schedule:
- cron: '0 2 * * 1-5' # 02:00 UTC, Monday–Friday
- cron: '0 6 * * 0' # 06:00 UTC, Sunday (weekly full scan)
```
All times are UTC. GitHub may delay scheduled runs by up to 15 minutes during high load. Scheduled workflows are automatically disabled if the repository has no activity for 60 days.
### workflow_call — Reusable Workflow Interface
```yaml
on:
workflow_call:
inputs:
environment:
type: string
required: true
image_tag:
type: string
required: true
secrets:
DEPLOY_TOKEN:
required: true
outputs:
deployment_url:
value: ${{ jobs.deploy.outputs.url }}
```
### repository_dispatch — External Triggers
```yaml
on:
repository_dispatch:
types:
- deploy-triggered
- integration-test-requested
```
Trigger via the GitHub API:
```bash
curl -X POST \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/ORG/REPO/dispatches \
-d '{"event_type":"deploy-triggered","client_payload":{"version":"1.4.2"}}'
```
Access the payload as `${{ github.event.client_payload.version }}`.
---
## Practical Examples
### Example 1: Basic CI Pipeline
```yaml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Type check
run: npm run typecheck
- name: Unit tests
run: npm test -- --coverage --ci
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 7
```
### Example 2: Build and Publish Docker Image
```yaml
name: Docker Build and Push
on:
push:
tags: ['v*.*.*']
pull_request:
branches: [main]
permissions:
contents: read
packages: write
id-token: write # for OIDC / Sigstore signing
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=ref,event=pr
type=sha,prefix=sha-
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true # SLSA Level 3 provenance
sbom: true # Software Bill of Materials
- name: Sign image with Cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@v3
- name: Sign
if: github.event_name != 'pull_request'
run: |
cosign sign --yes ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
```
### Example 3: Multi-Stage CI/CD Pipeline
```yaml
name: CI/CD Pipeline
on:
push:
branches: [main]
permissions: {}
jobs:
lint-and-test:
name: Lint & Test
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
test-passed: ${{ steps.test.outcome }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install -r requirements.txt
- run: ruff check .
- id: test
run: pytest --junitxml=results.xml
- uses: actions/upload-artifact@v4
with:
name: test-results
path: results.xml
security-scan:
name: Security Scan
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- name: Trivy filesystem scan
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
format: 'sarif'
output: 'trivy.sarif'
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy.sarif
build:
name: Build Image
needs: [lint-and-test, security-scan]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image-tag: ${{ steps.meta.outputs.version }}
image-digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@v4
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: type=sha,prefix=
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: push
uses: docker/build-push-action@v6
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-staging:
name: Deploy → Staging
needs: build
uses: ./.github/workflows/_deploy.yml
with:
environment: staging
image-tag: ${{ needs.build.outputs.image-tag }}
secrets: inherit
integration-tests:
name: Integration Tests
needs: deploy-staging
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run test:integration
env:
BASE_URL: https://staging.example.com
deploy-production:
name: Deploy → Production
needs: [build, integration-tests]
uses: ./.github/workflows/_deploy.yml
with:
environment: production
image-tag: ${{ needs.build.outputs.image-tag }}
secrets: inherit
```
### Example 4: Terraform Deployment
```yaml
name: Terraform
on:
push:
branches: [main]
paths: ['infra/**']
pull_request:
paths: ['infra/**']
permissions:
contents: read
id-token: write # OIDC for AWS
pull-requests: write # post plan as PR comment
jobs:
terraform:
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra/
steps:
- uses: actions/checkout@v4
- name: Configure AWS via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/github-terraform
aws-region: eu-west-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: '1.9.0'
terraform_wrapper: true
- name: Terraform Format Check
run: terraform fmt -check -recursive
continue-on-error: true
- name: Terraform Init
run: terraform init -backend-config=backend.hcl
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
id: plan
run: terraform plan -no-color -out=tfplan
continue-on-error: true
- name: Post Plan to PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const plan = `${{ steps.plan.outputs.stdout }}`;
const body = `### Terraform Plan\n\`\`\`\n${plan}\n\`\`\``;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body
});
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && steps.plan.outcome == 'success'
run: terraform apply -auto-approve tfplan
```
### Example 5: Kubernetes Deployment
```yaml
name: Kubernetes Deploy
on:
workflow_call:
inputs:
environment:
type: string
required: true
image-tag:
type: string
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: ${{ inputs.environment }}
url: https://${{ inputs.environment }}.example.com
concurrency:
group: deploy-${{ inputs.environment }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: eu-west-1
- name: Update kubeconfig
run: |
aws eks update-kubeconfig \
--name ${{ vars.EKS_CLUSTER_NAME }} \
--region eu-west-1
- name: Helm deploy
run: |
helm upgrade --install myapp ./chart \
--namespace ${{ inputs.environment }} \
--create-namespace \
--set image.tag=${{ inputs.image-tag }} \
--set environment=${{ inputs.environment }} \
--values ./chart/values/${{ inputs.environment }}.yaml \
--wait \
--timeout 10m \
--atomic
- name: Verify rollout
run: |
kubectl rollout status deployment/myapp \
-n ${{ inputs.environment }} \
--timeout=5m
- name: Smoke test
run: |
curl -f https://${{ inputs.environment }}.example.com/health || exit 1
```
---
## GitHub-Hosted Runners
GitHub provides managed virtual machines for three operating systems. All runners include pre-installed tools (Docker, kubectl, Helm, AWS CLI, Azure CLI, Python, Node.js, Go, Java, etc.).
|Runner Label|OS|vCPU|RAM|Storage|Cost Multiplier|
|---|---|---|---|---|---|
|`ubuntu-latest` (ubuntu-24.04)|Ubuntu 24.04|4|16 GB|14 GB SSD|1×|
|`ubuntu-22.04`|Ubuntu 22.04|4|16 GB|14 GB SSD|1×|
|`ubuntu-latest` (Large)|Ubuntu 24.04|16|64 GB|150 GB SSD|4×|
|`windows-latest`|Windows Server 2022|4|16 GB|14 GB SSD|2×|
|`macos-latest` (M1)|macOS 14|3 (Apple M1)|7 GB|14 GB SSD|5×|
|`macos-13` (Intel)|macOS 13|4 (Intel)|14 GB|14 GB SSD|10×|
**Free tier**: 2,000 Linux minutes per month (GitHub Free/Pro). Organization plans start at 3,000 minutes. macOS minutes are counted at 10× the rate.
**Larger runners** (4–64 vCPU) are available on GitHub Team and Enterprise plans. They are useful for heavy compilation, large test suites, or parallel processing within a single job.
---
## Self-Hosted Runners
### When to Use Self-Hosted Runners
Self-hosted runners make sense when:
- You need access to a private network (VPC, on-premises databases)
- Jobs require specialized hardware (GPU, hardware security modules, ARM devices)
- You process data that must not leave your environment (regulated industries)
- Volume is high enough that per-minute costs exceed infrastructure costs
- You need consistent environments with pre-warmed caches
### Advantages
- Full control over hardware and software
- No per-minute billing — only infrastructure cost
- Access to private networks without VPN or tunnel
- Custom labels for routing jobs to specific machines
- Pre-warmed caches (Docker layers, dependency stores)
### Disadvantages
- You own availability, patching, and security hardening
- Horizontal scaling requires additional tooling (Actions Runner Controller)
- Ephemeral runners require more setup than persistent ones
- GitHub is not responsible for the security of the runner environment
### Security Implications
**Never register self-hosted runners to public repositories.** A malicious actor can open a pull request with code that executes on your runner, accessing your internal network, credentials, and any data on the filesystem.
For private repositories, follow these controls:
- Use ephemeral runners (register, run one job, deregister). The `--ephemeral` flag in the runner binary supports this.
- Run runners in containers or VMs that are destroyed after each job.
- Never give runners elevated privileges (no `root`, no `sudo`).
- Isolate runner networks from production systems.
- Use Actions Runner Controller (ARC) on Kubernetes for automated ephemeral runner lifecycle.
### Actions Runner Controller (ARC)
ARC is the Kubernetes operator for self-hosted runners. It provisions ephemeral runner pods on demand and terminates them after each job.
```yaml
# values.yaml for ARC installation
githubConfigUrl: https://github.com/org/repo
githubConfigSecret: arc-github-secret
template:
spec:
containers:
- name: runner
image: ghcr.io/actions/actions-runner:latest
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: 2
memory: 4Gi
```
### Enterprise Use Cases
Large financial institutions and cloud-native companies typically run self-hosted runners in Kubernetes (ARC) with:
- IRSA (IAM Roles for Service Accounts) for AWS access without static credentials
- Workload Identity for GCP access
- Network policies isolating runner pods
- Images built from hardened base images (CIS benchmarks)
- Centralised logging to SIEM (Splunk, Datadog)
---
## Security Best Practices and DevSecOps
Security in GitHub Actions is a multi-layer discipline. Each layer addresses a different threat vector.
### 1. Secrets Management
```yaml
# Good: inject secret as environment variable at step level
- name: Deploy
env:
API_KEY: ${{ secrets.API_KEY }}
run: deploy.sh
# Bad: never interpolate secrets directly into run commands
# run: curl -H "Authorization: Bearer ${{ secrets.API_KEY }}" ...
# Reason: the value appears in the workflow YAML and process list
```
For external secret stores, use dedicated Actions:
- **HashiCorp Vault**: `hashicorp/vault-action`
- **AWS Secrets Manager**: `aws-actions/aws-secretsmanager-get-secrets`
- **Azure Key Vault**: `azure/get-keyvault-secrets`
### 2. OIDC — Keyless Cloud Authentication
OIDC eliminates long-lived static credentials from secrets entirely. The workflow requests a short-lived token from GitHub's OIDC provider, exchanges it with the cloud provider, and receives a scoped, time-limited credential.
```yaml
permissions:
id-token: write # required for OIDC token issuance
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
# AWS — configure the IAM role trust policy for GitHub OIDC
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
role-session-name: ${{ github.run_id }}
aws-region: eu-west-1
# GCP
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/123/locations/global/workloadIdentityPools/github/providers/github
service_account:
[email protected]
# Azure
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
```
**AWS IAM Trust Policy for GitHub OIDC:**
```json
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:*"
}
}
}]
}
```
### 3. Least Privilege Permissions
Set `permissions: {}` at the workflow level (disabling all permissions) and grant only what each job needs:
```yaml
permissions: {} # deny all by default
jobs:
test:
permissions:
contents: read # clone the repo
runs-on: ubuntu-latest
publish:
permissions:
contents: read
packages: write # push to GHCR
id-token: write # OIDC token
pr-comment:
permissions:
pull-requests: write # post comment
contents: read
```
### 4. Action Pinning (Supply Chain Security)
Pinning to a full commit SHA prevents a compromised tag from executing malicious code:
```yaml
# Vulnerable — a tag can be moved to a different commit
- uses: actions/checkout@v4
# Secure — SHA cannot be modified after the fact
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
```
Use [Dependabot for GitHub Actions](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot) to automate SHA updates:
```yaml
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
```
### 5. Dependency Verification
```yaml
# Verify checksums for downloaded tools
- name: Install cosign
uses: sigstore/cosign-installer@11086d25041f77fe8fe7b9ea4e48e3b9192b8f19 # v3.1.2
# Use SLSA verifier for build artifacts
- name: Verify artifact provenance
uses: slsa-framework/slsa-verifier/actions/installer@c9abffe4d2ab2ffa0b2ea9b2582b84164f390adc
```
### 6. Security Summary Checklist
|Control|Implementation|
|---|---|
|Short-lived credentials|OIDC for AWS, GCP, Azure|
|No static secrets in code|Use `secrets:` context, never hardcode|
|Action pinning|SHA pinning + Dependabot|
|Least privilege|`permissions: {}` + per-job grants|
|Ephemeral runners|ARC or GitHub-hosted|
|Branch protection|Required status checks on `main`|
|Environment approvals|Required reviewers for production|
|Audit trail|GitHub audit log (org/enterprise level)|
---
## Reusable Workflows
Reusable workflows are the primary mechanism for applying DRY principles and platform governance to GitHub Actions at scale.
### Defining a Reusable Workflow
```yaml
# .github/workflows/_deploy.yml
# Convention: prefix with _ to indicate internal/reusable
on:
workflow_call:
inputs:
environment:
type: string
required: true
image-tag:
type: string
required: true
dry-run:
type: boolean
default: false
secrets:
KUBE_CONFIG:
required: true
outputs:
deployment-url:
description: "URL of the deployed application"
value: ${{ jobs.deploy.outputs.url }}
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
outputs:
url: ${{ steps.verify.outputs.url }}
steps:
- uses: actions/checkout@v4
- name: Deploy
if: inputs.dry-run == false
run: |
echo "Deploying ${{ inputs.image-tag }} to ${{ inputs.environment }}"
- id: verify
run: echo "url=https://${{ inputs.environment }}.example.com" >> $GITHUB_OUTPUT
```
### Calling a Reusable Workflow
```yaml
jobs:
deploy-staging:
uses: myorg/.github/.github/workflows/_deploy.yml@main
with:
environment: staging
image-tag: ${{ needs.build.outputs.tag }}
secrets:
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG_STAGING }}
deploy-production:
needs: deploy-staging
uses: myorg/.github/.github/workflows/_deploy.yml@main
with:
environment: production
image-tag: ${{ needs.build.outputs.tag }}
secrets: inherit # pass all secrets from calling workflow
```
### Platform Engineering Approach: Centralized Workflow Library
Store reusable workflows in a central `myorg/.github` repository:
```
myorg/.github/
├── .github/
│ └── workflows/
│ ├── _ci-node.yml # Node.js CI template
│ ├── _ci-python.yml # Python CI template
│ ├── _ci-go.yml # Go CI template
│ ├── _docker-build.yml # Container build template
│ ├── _deploy-k8s.yml # Kubernetes deployment template
│ ├── _deploy-lambda.yml # Lambda deployment template
│ ├── _security-scan.yml # Security scanning template
│ └── _terraform.yml # Infrastructure template
├── actions/
│ └── setup-internal-tools/ # Composite action
│ └── action.yml
└── README.md
```
Application teams consume these without writing CI logic:
```yaml
# In application repo
jobs:
ci:
uses: myorg/.github/.github/workflows/_ci-node.yml@main
with:
node-version: '20'
deploy:
needs: ci
uses: myorg/.github/.github/workflows/_deploy-k8s.yml@main
with:
environment: production
chart-path: ./chart
secrets: inherit
```
**Benefits of centralized governance:**
- Platform team controls security, compliance, and standards in one place
- A single PR to `myorg/.github` updates CI for all consuming repositories
- Teams cannot bypass security scans or approval gates
- Audit trail for all workflow changes
---
## Composite Actions
A **composite action** bundles multiple steps into a single reusable unit, published with its own `action.yml`.
### When to Use Composite Actions vs. Reusable Workflows
|Criterion|Composite Action|Reusable Workflow|
|---|---|---|
|Unit of composition|Step|Job (or set of jobs)|
|Can contain jobs?|No|Yes|
|Uses secrets?|Via inputs|Via `secrets:` interface|
|Best for|Tool setup, utility steps|Complete pipeline stages|
|Published to Marketplace?|Yes|No|
### Example: Composite Action for Internal Setup
```yaml
# actions/setup-platform/action.yml
name: 'Platform Setup'
description: 'Installs internal tools and configures auth'
inputs:
environment:
description: 'Target environment'
required: true
aws-region:
description: 'AWS region'
default: 'eu-west-1'
outputs:
cluster-name:
description: 'EKS cluster name'
value: ${{ steps.get-cluster.outputs.name }}
runs:
using: composite
steps:
- name: Install kubectl
shell: bash
run: |
curl -LO "https://dl.k8s.io/release/v1.31.0/bin/linux/amd64/kubectl"
chmod +x kubectl && sudo mv kubectl /usr/local/bin/
- name: Install Helm
shell: bash
run: |
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
- name: Install internal CLI
shell: bash
run: pip install mycompany-cli==2.1.0 --break-system-packages
- name: Get cluster name
id: get-cluster
shell: bash
env:
ENVIRONMENT: ${{ inputs.environment }}
run: |
NAME=$(mycompany-cli cluster get --env $ENVIRONMENT --output name)
echo "name=$NAME" >> $GITHUB_OUTPUT
```
**Usage in a workflow:**
```yaml
- name: Platform setup
uses: myorg/platform-actions/setup-platform@v2
with:
environment: production
- name: Deploy
run: helm upgrade --install myapp ./chart -n production
```
---
## Matrix Strategies
Matrix strategies run a single job configuration across a set of variable combinations, in parallel.
### Multi-Version, Multi-OS Testing
```yaml
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # don't cancel other matrix runs on first failure
max-parallel: 6
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python: ['3.10', '3.11', '3.12']
include:
# Add a specific combination with an extra variable
- os: ubuntu-latest
python: '3.13-dev'
experimental: true
exclude:
# Skip macOS + Python 3.10 (not supported)
- os: macos-latest
python: '3.10'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- run: pip install -e ".[test]"
- run: pytest
continue-on-error: ${{ matrix.experimental == true }}
```
### Dynamic Matrix from Script
```yaml
jobs:
generate-matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- id: set
run: |
# Generate matrix from changed services (monorepo pattern)
SERVICES=$(git diff --name-only HEAD~1 HEAD \
| grep '^services/' \
| cut -d/ -f2 \
| sort -u \
| jq -R . | jq -sc .)
echo "matrix={\"service\":$SERVICES}" >> $GITHUB_OUTPUT
build:
needs: generate-matrix
runs-on: ubuntu-latest
strategy:
matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- run: docker build services/${{ matrix.service }}
```
---
## Advanced Topics
### Concurrency Control
Concurrency groups prevent multiple deployments to the same environment running simultaneously:
```yaml
# Cancel previous runs on the same PR branch
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# For deployments: queue runs, never cancel in-progress deploy
jobs:
deploy:
concurrency:
group: deploy-production
cancel-in-progress: false
```
### Caching
```yaml
- name: Cache Gradle dependencies
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
# Docker layer cache via GitHub Actions Cache
- uses: docker/build-push-action@v6
with:
cache-from: type=gha
cache-to: type=gha,mode=max
```
### Monorepo Support
Efficient monorepo CI triggers only the services affected by a change:
```yaml
on:
push:
paths:
- 'services/api/**'
- 'libs/shared/**' # rebuild api if shared lib changes
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
api: ${{ steps.changes.outputs.api }}
web: ${{ steps.changes.outputs.web }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: changes
with:
filters: |
api:
- 'services/api/**'
- 'libs/shared/**'
web:
- 'services/web/**'
- 'libs/shared/**'
build-api:
needs: detect-changes
if: needs.detect-changes.outputs.api == 'true'
runs-on: ubuntu-latest
steps:
- run: echo "Building API"
```
### Conditional Execution and Expressions
```yaml
steps:
# Run only on main branch push
- if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: deploy.sh
# Always run (even if previous steps failed)
- if: always()
run: cleanup.sh
# Run only if a specific job succeeded
- if: needs.test.result == 'success' && !cancelled()
run: publish.sh
# Use contains() / startsWith() / endsWith()
- if: contains(github.event.pull_request.labels.*.name, 'deploy-to-staging')
run: deploy-staging.sh
```
### Dynamic Outputs and Step Summary
```yaml
- name: Generate version
id: version
run: |
TAG="v$(date +%Y.%m.%d)-${{ github.run_number }}"
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "sha=${GITHUB_SHA:0:8}" >> $GITHUB_OUTPUT
- name: Report
run: |
cat >> $GITHUB_STEP_SUMMARY << 'EOF'
## Deployment Summary 🚀
| Key | Value |
|-----|-------|
| Version | ${{ steps.version.outputs.tag }} |
| SHA | ${{ steps.version.outputs.sha }} |
| Environment | production |
| Status | ✅ Success |
EOF
```
---
## GitOps Integration
GitHub Actions is a natural fit for GitOps workflows, where the Git repository is the single source of truth for both application code and infrastructure state.
### Argo CD Integration
```yaml
# Trigger Argo CD sync after image push
- name: Update image tag in GitOps repo
uses: actions/checkout@v4
with:
repository: myorg/gitops-config
token: ${{ secrets.GITOPS_PAT }}
path: gitops
- name: Update Helm values
run: |
cd gitops
yq e -i '.image.tag = "${{ inputs.image-tag }}"' \
apps/myapp/values-${{ inputs.environment }}.yaml
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git commit -am "chore: update myapp to ${{ inputs.image-tag }}"
git push
# Argo CD watches the gitops repo and auto-syncs
```
Alternatively, trigger sync directly:
```yaml
- name: Argo CD Sync
run: |
argocd app sync myapp-${{ inputs.environment }} \
--auth-token ${{ secrets.ARGOCD_TOKEN }} \
--server argocd.internal.example.com \
--grpc-web \
--timeout 300
```
### FluxCD Integration
With FluxCD, GitHub Actions typically updates image tags in the GitOps repository, and Flux's image automation controller reconciles the cluster state:
```yaml
- name: Update image policy
run: |
flux reconcile image repository myapp \
--kubeconfig ${{ steps.kubeconfig.outputs.path }}
```
### Infrastructure as Code with Terraform
The Terraform example in Example 4 demonstrates the complete pattern:
1. `pull_request` → plan + post comment
2. `push` to `main` → apply
This "plan on PR, apply on merge" pattern is the standard GitOps approach for infrastructure.
---
## GitHub Actions for Platform Engineering
Platform engineering teams use GitHub Actions as the automation backbone of their **Internal Developer Platform (IDP)**. The goal is to create **golden paths** — opinionated, pre-approved routes for common development tasks.
### Golden Paths
A golden path is a templated workflow that encodes the platform team's best practices:
```yaml
# .github/workflows/_golden-path-microservice.yml
# Golden path for deploying a microservice to Kubernetes
on:
workflow_call:
inputs:
service-name:
type: string
required: true
environment:
type: string
required: true
jobs:
compliance-check:
name: Compliance Validation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check for required files
run: |
for f in Dockerfile chart/Chart.yaml OWNERS.md; do
[ -f "$f" ] || (echo "Missing required file: $f" && exit 1)
done
- name: Validate SBOM policy
run: sbom-checker --policy ./policy/sbom.yaml
security-gate:
name: Security Gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aquasecurity/trivy-action@master
with:
exit-code: '1'
severity: 'HIGH,CRITICAL'
build:
needs: [compliance-check, security-gate]
uses: myorg/.github/.github/workflows/_docker-build.yml@main
with:
service-name: ${{ inputs.service-name }}
deploy:
needs: build
uses: myorg/.github/.github/workflows/_deploy-k8s.yml@main
with:
environment: ${{ inputs.environment }}
secrets: inherit
```
### Developer Self-Service
Platform teams expose `workflow_dispatch` as a self-service interface:
```yaml
on:
workflow_dispatch:
inputs:
action:
type: choice
options: [create-namespace, rotate-secrets, run-migration, enable-feature-flag]
target:
type: string
description: 'Service or resource name'
```
This enables developers to perform approved operations without filing tickets or waiting for an ops engineer.
### SDLC Governance
GitHub Actions enforces SDLC standards through required workflows at the organization level (GitHub Enterprise feature):
- All repositories must pass a central security scan before merging
- Required deployment workflow enforces DORA metrics collection
- Mandatory evidence generation (test results, SBOM, provenance) for DORA compliance
### Automated Compliance Evidence
```yaml
- name: Generate compliance evidence
run: |
# DORA change failure rate tracking
cat > evidence.json << EOF
{
"deployment_id": "${{ github.run_id }}",
"service": "${{ inputs.service-name }}",
"environment": "${{ inputs.environment }}",
"image_tag": "${{ inputs.image-tag }}",
"deployed_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"deployed_by": "${{ github.actor }}",
"workflow": "${{ github.workflow }}",
"sha": "${{ github.sha }}"
}
EOF
aws s3 cp evidence.json \
s3://compliance-evidence/deployments/${{ github.run_id }}.json
```
---
## Enterprise-Scale GitHub Actions
### Governance Framework
At enterprise scale, GitHub Actions governance operates across three layers:
**Organization-level policies** (GitHub Enterprise):
- Which Actions are allowed (allow-list by owner, verified creator, or specific SHA)
- Whether self-hosted runners are permitted
- Required workflows that run on all repositories
- Default permissions for `GITHUB_TOKEN`
**Repository-level controls**:
- Branch protection rules (required status checks, required reviews)
- Environment protection rules (required approvers, deployment branch rules)
- Code owners for workflow files (`CODEOWNERS`: `.github/workflows/* @platform-team`)
**Workflow-level controls**:
- `permissions: {}` as default
- Action pinning enforced by CI
- Environment gates for production
### Compliance and Auditability
The GitHub Audit Log (available at organization and enterprise level) records:
- Every workflow run: who triggered it, from which branch, with what result
- Secret access events
- Runner registration and removal
- Permission changes
Export audit logs to SIEM systems:
```bash
# Stream audit log to Splunk via GitHub CLI
gh api /orgs/myorg/audit-log \
--paginate \
--field phrase="action:workflows.run" \
| jq . >> audit-export.jsonl
```
### Cost Management
|Strategy|Savings Potential|
|---|---|
|Cache dependencies aggressively|30–60% reduction in job duration|
|Use `paths:` filtering to skip irrelevant jobs|40–70% fewer runs for docs/config changes|
|Migrate high-volume jobs to self-hosted runners|60–80% cost reduction at scale|
|Use `cancel-in-progress: true` on PRs|Eliminate wasted minutes on superseded runs|
|Choose `ubuntu-latest` over Windows/macOS|2–10× cheaper per minute|
|Optimize Docker builds with layer caching|40–70% faster builds|
### Required Workflows (Enterprise)
GitHub Enterprise allows organization administrators to mandate that specific workflows run on all repositories and must pass before any merge:
```
Organization Settings → Actions → Required workflows
→ Add workflow: myorg/.github/.github/workflows/required-security-scan.yml@main
→ Apply to: All repositories
```
This ensures that no team can bypass security scanning, no matter what their local workflow configuration says.
---
## Real-World Enterprise Examples
### Banking — Compliance-First Deployment
A major bank's deployment pipeline enforces regulatory controls:
```yaml
jobs:
compliance-approval:
runs-on: ubuntu-latest
environment: production # Requires 2 senior approvers
generate-change-record:
needs: compliance-approval
runs-on: ubuntu-latest
steps:
- name: Create ServiceNow change ticket
run: |
curl -X POST ${{ vars.SNOW_ENDPOINT }}/api/now/table/change_request \
-H "Authorization: Bearer ${{ secrets.SNOW_TOKEN }}" \
-d '{"short_description":"Deploy ${{ inputs.service }} ${{ inputs.version }}"}'
deploy-canary:
needs: generate-change-record
runs-on: [self-hosted, linux, eks-prod]
steps:
- run: kubectl set image deployment/myapp myapp=myapp:${{ inputs.version }} -n prod
validate-canary:
needs: deploy-canary
steps:
- run: |
# Monitor error rate for 10 minutes
sleep 600
ERROR_RATE=$(datadog-metrics query 'errors.rate{service:myapp}')
[ "$ERROR_RATE" -lt "0.01" ] || (kubectl rollout undo deployment/myapp && exit 1)
deploy-full:
needs: validate-canary
environment: production-full # Second approval gate
```
### FinTech — Multi-Region Zero-Downtime Deploy
```yaml
jobs:
deploy-eu-west:
uses: ./.github/workflows/_deploy-region.yml
with:
region: eu-west-1
environment: production
deploy-us-east:
needs: deploy-eu-west
uses: ./.github/workflows/_deploy-region.yml
with:
region: us-east-1
environment: production
deploy-ap-southeast:
needs: deploy-us-east
uses: ./.github/workflows/_deploy-region.yml
with:
region: ap-southeast-1
environment: production
```
### SaaS — Feature Flag Automation
```yaml
on:
pull_request:
types: [labeled]
jobs:
enable-feature-flag:
if: contains(github.event.pull_request.labels.*.name, 'test-in-staging')
runs-on: ubuntu-latest
steps:
- name: Enable feature flag in staging
run: |
launchdarkly-cli flag update \
--flag ${{ github.head_ref }} \
--env staging \
--on true
```
---
## Comparison with Other CI/CD Systems
|Feature|GitHub Actions|Jenkins|GitLab CI/CD|Azure DevOps|CircleCI|TeamCity|Argo Workflows|
|---|---|---|---|---|---|---|---|
|**Ease of use**|⭐⭐⭐⭐⭐|⭐⭐|⭐⭐⭐⭐|⭐⭐⭐|⭐⭐⭐⭐|⭐⭐⭐|⭐⭐|
|**Scalability**|⭐⭐⭐⭐|⭐⭐⭐|⭐⭐⭐⭐|⭐⭐⭐⭐|⭐⭐⭐⭐|⭐⭐⭐⭐|⭐⭐⭐⭐⭐|
|**Security**|⭐⭐⭐⭐⭐|⭐⭐|⭐⭐⭐⭐|⭐⭐⭐⭐|⭐⭐⭐|⭐⭐⭐|⭐⭐⭐|
|**Extensibility**|⭐⭐⭐⭐⭐|⭐⭐⭐⭐⭐|⭐⭐⭐⭐|⭐⭐⭐⭐|⭐⭐⭐|⭐⭐⭐⭐|⭐⭐⭐⭐|
|**GitHub native**|⭐⭐⭐⭐⭐|⭐⭐|⭐⭐|⭐⭐|⭐⭐⭐|⭐⭐|N/A|
|**Cost**|Free tier, then per-min|Infrastructure only|Free tier on GitLab.com|Included with M365|Per-min|Per-agent|Infrastructure only|
|**Learning curve**|Low|High|Medium|Medium|Low|Medium|Very High|
### Jenkins
Jenkins is the most flexible CI system and has the largest plugin ecosystem, but it requires significant operational overhead: server management, plugin updates, security patching, backup, and scaling. Its strength is extensibility for complex enterprise pipelines that have been built and tuned over years. The migration cost away from Jenkins is the primary reason organizations keep using it.
### GitLab CI/CD
GitLab CI is the closest structural equivalent to GitHub Actions. If your source control is GitLab, it is the natural choice. YAML syntax is similar. The main disadvantage is that it's only compelling if you use GitLab for source control — integrating with GitHub requires extra configuration.
### Azure DevOps Pipelines
Tightly integrated with Microsoft ecosystem (Azure, M365, Active Directory). Strong for .NET and Microsoft-stack shops. YAML pipelines are powerful but more complex than GitHub Actions. Best choice when Azure is the primary cloud and Microsoft enterprise licensing is in place.
### CircleCI
CircleCI has a similar philosophy to GitHub Actions (hosted, per-minute) and was an early pioneer. Its orbs system predates GitHub's reusable workflows. GitHub Actions has largely caught up in functionality, and the lack of native GitHub integration puts CircleCI at a disadvantage for GitHub-hosted repos.
### TeamCity
JetBrains TeamCity is popular in enterprises with existing JetBrains tooling (IntelliJ, YouTrack). It offers excellent build chain management and Kotlin DSL for pipeline-as-code. Requires self-managed server infrastructure.
### Argo Workflows
Argo Workflows is a Kubernetes-native workflow engine, best suited for data pipelines, ML training jobs, and complex DAG-based workflows. It is not a code-delivery CI system by default — it complements GitHub Actions rather than replaces it. The combination of GitHub Actions (for CI, security scanning, Docker build) and Argo Workflows (for long-running ML pipelines or data ETL) is a common pattern in cloud-native organizations.
---
## Common Mistakes and Anti-Patterns
### 1. Checking Out and Executing Fork Code in `pull_request_target`
```yaml
# DANGEROUS - gives secrets access to fork code
on:
pull_request_target:
jobs:
build:
steps:
- uses: actions/checkout@v4 # checks out fork code with base branch context
- run: npm test # executes attacker-controlled code with secrets access
```
**Fix**: If you need `pull_request_target` for secrets, separate the checkout and the secret-using steps, or use `pull_request` for untrusted code.
### 2. Using Mutable Tags for Third-Party Actions
```yaml
# Bad - tag can be moved to a different commit
- uses: actions/checkout@v4
# Good
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
```
### 3. Printing Secrets in Run Steps
```yaml
# Bad - leaks secret if echo is in run
- run: echo "Token is ${{ secrets.API_TOKEN }}"
# GitHub masks known secrets, but this is still bad practice
# and derived values (base64, reversed) are not masked
```
### 4. `permissions: write-all` as Default
Many tutorials use `permissions: write-all` to avoid permission errors. This violates the principle of least privilege and grants unnecessary access to every resource.
### 5. Not Using Concurrency for Deployments
Without concurrency control, two pushes to `main` in quick succession can trigger two simultaneous production deploys, causing race conditions and undefined state.
### 6. Storing Secrets in Environment Variables at Workflow Level
```yaml
# Bad - secret visible to all jobs and potentially in logs
env:
API_KEY: ${{ secrets.API_KEY }}
# Good - scope to the specific step that needs it
- name: Deploy
env:
API_KEY: ${{ secrets.API_KEY }}
run: deploy.sh
```
### 7. Large Monolithic Workflows
A single 500-line workflow file that does everything is hard to maintain and impossible to reuse. Break into reusable workflows and composite actions.
### 8. Not Caching Dependencies
Every job that runs `npm install` or `pip install` without caching downloads the internet on every run. This is slow and expensive.
### 9. Ignoring `fail-fast` in Matrices
The default `fail-fast: true` cancels all matrix jobs when one fails. For cross-platform testing, this prevents you from seeing which platforms passed and which failed. Set `fail-fast: false` for diagnostic clarity.
---
## Performance and Cost Optimization
### Performance
**Cache everything that changes infrequently:**
```yaml
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # automatic npm cache
# Manual cache for custom paths
- uses: actions/cache@v4
with:
path: ~/.cargo/registry
key: cargo-${{ hashFiles('Cargo.lock') }}
```
**Parallelize aggressively:**
- Split lint, test, and build into separate parallel jobs
- Use matrix for cross-platform/cross-version runs
- Run security scans in parallel with unit tests
**Use `paths:` filters** to skip workflows for irrelevant changes:
```yaml
on:
push:
paths-ignore:
- 'docs/**'
- '*.md'
- '.github/ISSUE_TEMPLATE/**'
```
**Prefer `ubuntu-latest`**: It is 2× cheaper than Windows and 10× cheaper than macOS per minute.
### Cost Optimization
|Technique|Impact|
|---|---|
|Cache dependencies|High — reduces job duration 30–60%|
|`paths:` filtering|High — eliminates unnecessary runs entirely|
|`cancel-in-progress: true` on PR branches|Medium — cancels stale runs|
|Migrate bulk to self-hosted runners|Very high at scale|
|Use smaller runner sizes where possible|Medium|
|Set appropriate artifact `retention-days`|Low — storage cost|
|Audit and remove unused workflows|Low-Medium|
Estimate your monthly cost:
```
Monthly cost = (average_job_minutes × jobs_per_day × working_days × os_multiplier × price_per_minute)
```
GitHub charges $0.008/minute for Linux, $0.016/minute for Windows, $0.08/minute for macOS (as of 2025).
---
## Migration Guides
### Jenkins to GitHub Actions
**Conceptual mapping:**
|Jenkins|GitHub Actions|
|---|---|
|`Jenkinsfile`|`.github/workflows/*.yml`|
|Stage|Job|
|Step|Step|
|Agent|Runner (`runs-on:`)|
|Shared Library|Reusable workflow / Composite action|
|Credentials|Secrets|
|Plugin|Marketplace Action|
|Pipeline input()|`workflow_dispatch` + `environment`|
**Example migration:**
Jenkins:
```groovy
pipeline {
agent { label 'linux' }
stages {
stage('Test') {
steps {
sh 'npm ci && npm test'
}
}
stage('Deploy') {
when { branch 'main' }
steps {
withCredentials([string(credentialsId: 'api-key', variable: 'API_KEY')]) {
sh './deploy.sh'
}
}
}
}
}
```
GitHub Actions equivalent:
```yaml
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
deploy:
if: github.ref == 'refs/heads/main'
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh
env:
API_KEY: ${{ secrets.API_KEY }}
```
**Migration strategy:**
1. Inventory existing pipelines — identify unique patterns and shared libraries
2. Map shared library functions to composite actions or reusable workflows
3. Migrate lowest-risk pipelines first (feature teams, non-production)
4. Keep Jenkins running in parallel during migration
5. Establish the central workflow library before migrating many repositories
6. Automate credential migration from Jenkins to GitHub Secrets
### Azure DevOps to GitHub Actions
|Azure DevOps|GitHub Actions|
|---|---|
|`azure-pipelines.yml`|`.github/workflows/*.yml`|
|Stage|Job|
|Job|Job|
|Task|Step (`uses:` or `run:`)|
|Variable group|Organization/repo variables|
|Key Vault integration|OIDC + `azure/get-keyvault-secrets`|
|Environments|Environments|
|Service connections|OIDC + Federated credentials|
**Key differences**: Azure DevOps has stages inside pipelines (stages → jobs → steps). GitHub Actions has only jobs → steps. Stages are modeled as jobs with `needs:` dependencies.
### GitLab CI to GitHub Actions
|GitLab CI|GitHub Actions|
|---|---|
|`.gitlab-ci.yml`|`.github/workflows/*.yml`|
|Stage|(modeled via `needs:`)|
|Job|Job|
|Script|`run:`|
|`include:`|`uses:` (reusable workflow)|
|Variables|`vars:` / `secrets:`|
|Protected variables|Environment secrets|
|Environments|Environments|
|Runner|Runner|
**Structural difference**: GitLab CI uses stages to define ordering; GitHub Actions uses explicit `needs:` dependencies. This gives GitHub Actions more flexibility (any DAG topology) but requires more explicit dependency declarations.
---
## Key Takeaways, Best Practices Checklist, and Future Trends
### Key Takeaways
1. **GitHub Actions is a platform, not just a CI runner.** Its event system, reusable workflows, environments, and OIDC integration make it suitable for the full software delivery lifecycle.
2. **Security must be designed in, not added later.** Pin Actions by SHA, use OIDC, set `permissions: {}`, scope secrets to environments.
3. **Reusable workflows are the platform engineering primitive.** Centralize pipeline logic in `myorg/.github` and let teams consume, not define, their CI/CD.
4. **OIDC is the correct credential model for cloud deployments.** Eliminate static AWS/GCP/Azure keys from secrets entirely.
5. **Self-hosted runners are not optional at regulated-industry scale.** Private network access, compliance data isolation, and cost at volume all drive self-hosted adoption.
6. **Monorepo support requires thoughtful design.** Dynamic matrices, `paths:` filters, and change detection patterns are essential.
### Recommended Architecture for 2026
```
Platform Team owns:
myorg/.github
├── .github/workflows/
│ ├── _ci-{language}.yml # Language-specific CI templates
│ ├── _docker-build.yml # Container build with SLSA + Sigstore
│ ├── _deploy-k8s.yml # Kubernetes deployment via Helm
│ ├── _deploy-serverless.yml # Lambda / Cloud Run deployment
│ ├── _terraform.yml # Infrastructure management
│ ├── _security-scan.yml # Mandatory security gate
│ ├── _compliance-evidence.yml # DORA / SOC2 evidence collection
│ └── required-gate.yml # Org-required workflow (Enterprise)
└── actions/
├── setup-platform/ # Internal tool setup composite
├── notify-slack/ # Notification composite
└── generate-evidence/ # Compliance evidence composite
Application Teams consume:
myservice/.github/workflows/
├── ci.yml → uses: myorg/.github/_ci-node.yml@main
└── cd.yml → uses: myorg/.github/_deploy-k8s.yml@main
Infrastructure:
- Actions Runner Controller (ARC) on Kubernetes
- IRSA / Workload Identity for cloud credentials
- GitHub Enterprise with Required Workflows
- Audit log → Splunk / Datadog
```
### Future Trends in CI/CD and GitHub Actions
**AI-assisted workflows.** GitHub Copilot is already generating workflow files. The next step is AI agents that observe build failures and suggest fixes, optimize caching automatically, or generate security patches.
**Mandatory SLSA compliance.** Supply chain security is moving from best practice to regulatory requirement, particularly in financial services and government sectors. GitHub's native SLSA Level 3 provenance support positions Actions well.
**Merge queues and queue-based deployments.** GitHub's Merge Queue feature (GA in 2023) integrates with Actions to run pre-merge CI on batches of PRs, significantly increasing throughput for high-velocity teams.
**Dagger and portable CI.** Projects like Dagger (container-native CI SDK) aim to make pipeline logic portable across CI systems. This may reduce CI lock-in but does not eliminate the need for an orchestration layer like GitHub Actions.
**eBPF-based runner observability.** Deep observability of runner behavior (system calls, network connections) using eBPF will become standard for high-security environments, enabling anomaly detection and supply chain attack prevention at the runner level.
**GitHub Actions as an IDP backbone.** The trend of using GitHub Actions as the automation layer for Internal Developer Platforms will accelerate, with tighter integrations into backstage.io, Port, Cortex, and similar IDP frameworks for service catalog integration and self-service provisioning.
---
_GitHub Actions — CI/CD — DevOps — Platform Engineering — GitOps — Software Delivery — Developer Experience — DevSecOps — Kubernetes — OIDC — SLSA — Supply Chain Security_
---
**About this article:** Written for engineering practitioners who need both the conceptual framework and the practical YAML to implement GitHub Actions at scale. All examples are based on real-world patterns observed in banking, FinTech, SaaS, and cloud-native engineering organizations.