GitHub Actions CI/CD for .NET 2026 — OIDC, Egress Firewall, and Immutable Actions for Production Pipelines
Posted on: 4/17/2026 3:11:11 PM
Table of contents
- CI/CD pipeline architecture for .NET 10
- Caching strategy — 80% build time saved
- A complete workflow YAML for .NET 10
- OIDC — Zero-credential deploys to Azure
- Supply-chain security — The 2026 Security Roadmap
- Parallel Steps — The most-requested feature
- Reusable Workflows — DRY for multi-repo
- Matrix builds — Parallel testing across versions
- Runner pricing 2026 — Stronger and cheaper
- Production CI/CD security checklist
- GitHub Actions 2026 roadmap
- Conclusion
Every time a developer pushes code, a complex chain of events has to happen: build, test, static analysis, container packaging, deploy to staging, smoke test, then promote to production. Manually? 45 minutes and easy to miss a step. Automated with GitHub Actions? Under 8 minutes and never skips a step. In 2026, GitHub Actions is no longer just a CI/CD runner — it has become a full-fledged DevOps platform with enterprise-grade security, parallel steps, OIDC authentication, and an egress firewall — all natively integrated into GitHub.
This article digs into a CI/CD pipeline architecture for .NET 10 on GitHub Actions: from NuGet/Docker caching that cuts 80% of build time, reusable workflows for multi-repo setups, to supply-chain security with immutable actions and zero-credential deploys to Azure via OIDC.
CI/CD pipeline architecture for .NET 10
A production-grade .NET pipeline must clearly separate CI (Continuous Integration — every push) from CD (Continuous Deployment — only on merges to main). That line prevents accidental deploys and keeps the developer feedback loop fast.
graph LR
subgraph CI["CI — Every Push/PR"]
direction TB
A["Checkout + Cache Restore"] --> B["dotnet restore"]
B --> C["dotnet build"]
C --> D["dotnet test"]
D --> E["Code Analysis"]
end
subgraph CD["CD — Merge into main"]
direction TB
F["Docker Build + Push"] --> G["Deploy Staging"]
G --> H["Smoke Test"]
H --> I["Deploy Production"]
end
CI --> CD
style CI fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style CD fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
style A fill:#e94560,stroke:#fff,color:#fff
style I fill:#4CAF50,stroke:#fff,color:#fff
Figure 1: A clean CI/CD split for .NET 10
CI on every push, CD only on main
The golden rule: CI runs on every push and pull request to catch errors early. CD only triggers on merges to main — completely preventing accidental deploys from feature branches. This is the pattern officially recommended by GitHub for every production project.
Caching strategy — 80% build time saved
Cache is the single biggest lever on pipeline speed. On GitHub Actions there are two caches that matter for .NET: the NuGet package cache and the Docker BuildKit layer cache.
NuGet package caching
Without a cache, dotnet restore re-downloads every NuGet package each run — typically 60–90 seconds for a mid-sized project. With actions/cache keyed off the hash of .csproj files, a cache hit brings restore under 5 seconds.
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
The cache key hashes every .csproj — any dependency change correctly invalidates the cache. restore-keys provides a fallback to the newest cache for the same OS, still saving a lot compared to a cold download.
Docker BuildKit layer caching
For container-packaging pipelines, Docker BuildKit layer caching is a game-changer. Using the GitHub Actions cache backend (type=gha), build time drops from 4–5 minutes to under 60 seconds on cache hit.
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
The mode=max option caches every layer, including intermediates — ideal for Dockerfiles with many stages. Combine with docker/metadata-action to auto-generate tags: sha-abc1234 for traceability and latest for the main branch.
| Cache technique | No cache | Cache (hit) | Savings |
|---|---|---|---|
| NuGet Restore | 60–90s | <5s | ~93% |
| Docker Build | 240–300s | 30–60s | ~80% |
| dotnet build (incremental) | 45–60s | 10–15s | ~75% |
| Total pipeline | ~8–10 min | ~2–3 min | ~70% |
A complete workflow YAML for .NET 10
Below is a production-ready workflow combining CI + CD, NuGet cache, Docker build, and deploy via OIDC — with zero long-lived secrets.
name: CI/CD .NET 10
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
permissions:
contents: read
id-token: write # Required for OIDC
packages: write # Push containers to GHCR
env:
DOTNET_VERSION: '10.0.x'
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Cache NuGet
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: nuget-${{ runner.os }}-
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --no-restore -c Release
- name: Test
run: dotnet test --no-build -c Release --logger trx --results-directory results
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: results/*.trx
deploy:
needs: build-and-test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Login to Azure (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy to Azure Container Apps
uses: azure/container-apps-deploy-action@v2
with:
containerAppName: my-app
resourceGroup: my-rg
imageToDeploy: ${{ steps.meta.outputs.tags }}
About the id-token: write permission
The id-token: write permission lets the workflow request an OIDC token from GitHub. That token is short-lived (usually 10 minutes) and valid only for the exact repository and workflow that's running. Azure Entra ID verifies the token via federated credentials — no client secret or certificate needed. This is the only way you should deploy to the cloud in 2026.
OIDC — Zero-credential deploys to Azure
Traditionally, CI/CD pipelines had to store a service principal password as a GitHub Secret. The problem: secrets can leak through logs, forks, or compromised actions. OIDC (OpenID Connect) removes long-lived credentials entirely.
sequenceDiagram
participant GH as GitHub Actions
participant IDP as GitHub OIDC Provider
participant AZ as Azure Entra ID
participant RES as Azure Resources
GH->>IDP: Request OIDC token
IDP-->>GH: JWT token (repo, branch, workflow)
GH->>AZ: Present JWT token
AZ->>AZ: Verify issuer + subject claims
AZ-->>GH: Short-lived access token
GH->>RES: Deploy using the access token
Note over AZ,RES: No secrets stored anywhere
Figure 2: OIDC auth flow between GitHub Actions and Azure
Setting up OIDC on Azure
Azure-side config has three steps: create an App Registration, add a Federated Credential, and assign an RBAC role.
# 1. Create the App Registration
az ad app create --display-name "github-actions-deploy"
APP_ID=$(az ad app list --display-name "github-actions-deploy" --query "[0].appId" -o tsv)
# 2. Create the Service Principal
az ad sp create --id $APP_ID
# 3. Add a Federated Credential
az ad app federated-credential create --id $APP_ID --parameters '{
"name": "github-main-branch",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:myorg/myrepo:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}'
# 4. Grant role on the resource group
az role assignment create \
--assignee $APP_ID \
--role "Contributor" \
--scope "/subscriptions/{sub-id}/resourceGroups/my-rg"
The subject claim in the federated credential scopes exactly which repo and branch can deploy. That means even if an attacker forks the repo, the fork's OIDC token has a different subject and Azure rejects it — far more secure than static secrets.
New custom claims in 2026
GitHub just added repository custom properties to OIDC token claims. You can now write trust policies based on custom repo metadata — e.g. only allow deploys when a repo has the property environment: production. This attribute-based access control (ABAC) is more flexible than per-repo subject matching.
Supply-chain security — The 2026 Security Roadmap
In 2026, GitHub is investing heavily in CI/CD security with three pillars: Immutable Actions, Egress Firewall, and Actions Data Stream. This is a direct response to serious supply-chain attacks (like the Trivy compromise in March 2026).
Immutable Actions — Locking down dependencies
The core problem: action dependencies are resolved at runtime via mutable references (tags, branches). Today's v4 tag can point to a different commit tomorrow if the maintainer is compromised. That means CI/CD pipelines aren't deterministic.
GitHub's solution: a new dependencies: section in workflow YAML that locks every direct and transitive dependency by commit SHA. Every workflow run executes code you've actually reviewed — a hash mismatch halts execution before the job starts.
# Current pattern (UNSAFE)
- uses: actions/checkout@v4 # Tag can be moved
# Safe pattern (pin SHA)
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.7
# Future pattern (dependencies lock)
dependencies:
actions/checkout:
version: v4.1.7
sha: b4ffde65f46336ab88eb53be808477a3936bae11
# Transitive deps are locked too
Lesson from the Trivy Supply-Chain Attack (03/2026)
In March 2026, the aquasecurity/trivy-action was compromised — attackers injected malicious code into a mutable tag, hitting thousands of workflows. Pipelines that pinned SHAs were completely safe. Pipelines using @master or @latest tags were exploited. This is why pinning SHAs on every third-party action is non-negotiable.
Egress Firewall — Controlling outbound connections
GitHub is building a native egress firewall operating at Layer 7, living outside the runner VM. Even if an attacker gets root inside a runner, the firewall is immutable and can't be bypassed.
graph TB
subgraph Runner["GitHub-hosted Runner VM"]
W["Workflow Step"] --> NET["Outbound Request"]
end
subgraph FW["Egress Firewall (Layer 7)"]
NET --> CHECK["Allowlist check"]
CHECK -->|Allow| EXT["External Service"]
CHECK -->|Deny| BLOCK["Blocked + Logged"]
end
subgraph AUDIT["Audit Trail"]
CHECK --> LOG["Request → Workflow → Job → Step"]
end
style FW fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style BLOCK fill:#ff9800,stroke:#fff,color:#fff
style EXT fill:#4CAF50,stroke:#fff,color:#fff
Figure 3: Egress Firewall controlling outbound traffic from runners
Organizations can:
- Monitor: see every outbound request, auto-audited with the matching workflow/job/step
- Allowlist: only permit connections to required domains (nuget.org, ghcr.io, Azure endpoints)
- Block: fully block unwanted connections — preventing data exfiltration from a compromised step
Parallel Steps — The most-requested feature
Since 2020, GitHub Community Discussion #14484 about parallel steps has collected thousands of upvotes. In 2026, GitHub is officially building it, targeting H1 2026.
Currently, steps within a job run sequentially. To parallelize, you must split work into multiple jobs — complex and wasteful since each job needs its own runner. Parallel steps let independent steps run concurrently within the same job.
graph LR
subgraph Before["Today: sequential"]
direction TB
S1["Restore (30s)"] --> S2["Build (45s)"]
S2 --> S3["Unit Test (60s)"]
S3 --> S4["Integration Test (90s)"]
S4 --> S5["Lint (20s)"]
end
subgraph After["Parallel Steps"]
direction TB
P1["Restore + Build (50s)"] --> P2["Unit Test (60s)"]
P1 --> P3["Integration Test (90s)"]
P1 --> P4["Lint (20s)"]
end
style Before fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style After fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
Figure 4: Parallel steps cut total pipeline time from 245 s down to ~140 s
With parallel steps, a .NET pipeline can run unit tests, integration tests, and linting concurrently after build — total time drops from the sum of steps (sequential) to the slowest step (parallel).
Reusable Workflows — DRY for multi-repo
When an organization manages many .NET microservices, duplicating workflow YAML is a nightmare. Reusable Workflows let you define the pipeline once in a central repo and reuse it from every service repo.
# .github/workflows/dotnet-ci.yml (central repo: myorg/ci-templates)
name: .NET CI Template
on:
workflow_call:
inputs:
dotnet-version:
type: string
default: '10.0.x'
project-path:
type: string
required: true
secrets:
AZURE_CLIENT_ID:
required: true
AZURE_TENANT_ID:
required: true
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ inputs.dotnet-version }}
- run: dotnet restore ${{ inputs.project-path }}
- run: dotnet build ${{ inputs.project-path }} --no-restore -c Release
- run: dotnet test ${{ inputs.project-path }} --no-build -c Release
# .github/workflows/ci.yml (service repo)
name: CI
on: [push, pull_request]
jobs:
ci:
uses: myorg/ci-templates/.github/workflows/dotnet-ci.yml@main
with:
project-path: src/MyService.sln
secrets:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
Reusable Workflows + OIDC: Important caveat
When a reusable workflow needs OIDC, the caller workflow must declare permissions: id-token: write. The OIDC subject claim contains the caller repo's information (where it's invoked from), not the template repo's. That way the federated credential on Azure still matches the actual deploying repo.
Matrix builds — Parallel testing across versions
The matrix strategy lets you run the same test suite across multiple .NET versions, OSes, or databases simultaneously. Especially useful for NuGet libraries that must support multi-target.
jobs:
test:
strategy:
matrix:
dotnet: ['8.0.x', '9.0.x', '10.0.x']
os: [ubuntu-latest, windows-latest]
fail-fast: false
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ matrix.dotnet }}
- run: dotnet test --configuration Release
With fail-fast: false, all 6 combinations (3 .NET × 2 OS) run to completion even if one job fails — surfacing every compatibility issue in a single run instead of fixing them one at a time.
Runner pricing 2026 — Stronger and cheaper
In January 2026, GitHub significantly cut runner prices: the new 4-vCPU "Standard" runner costs the same as the old 2-vCPU runner from 2024. Double the compute at the same price.
| Runner type | vCPU | RAM | Price ($/min) | vs 2024 |
|---|---|---|---|---|
| ubuntu-latest (2024) | 2 | 7 GB | $0.008 | — |
| Standard Runner 2026 | 4 | 16 GB | $0.008 | -39% per vCPU |
| Large Runner | 8 | 32 GB | $0.016 | Unchanged |
| GPU Runner (Linux) | 4 | 28 GB + T4 | $0.07 | New 2025 |
The free tier is still extremely generous: 2,000 minutes/month for public repos (unlimited) and private repos on GitHub Free. With good caching, this is enough for a small team of 3-5 developers to run CI/CD every day.
Production CI/CD security checklist
Based on GitHub's 2026 Security Roadmap and lessons from supply-chain incidents, here's the essential security checklist for every .NET pipeline.
| Item | Practice | Level |
|---|---|---|
| Pin Actions | Pin every third-party action to a full SHA, never use tags | Mandatory |
| OIDC | Use OIDC instead of static credentials for cloud deploys | Mandatory |
| Permissions | Declare minimum permissions, never use default write-all |
Mandatory |
| Branch Protection | Require status checks + review before merging to main | Mandatory |
| Egress Firewall | Enable monitor mode, build an allowlist, then enforce | Recommended |
| Dependabot | Auto-update action versions + NuGet packages | Recommended |
| CODEOWNERS | Protect .github/workflows/ with CODEOWNERS |
Recommended |
| Secret Scanning | Enable push protection to block secret-laden commits | Mandatory |
GitHub Actions 2026 roadmap
Conclusion
GitHub Actions in 2026 is no longer just a "CI/CD tool" — it's a security-first DevOps platform. With egress firewall, immutable actions, and OIDC, CI/CD pipelines can for the first time reach the same security bar as production infrastructure. Combine with NuGet + Docker BuildKit caching that cuts build time by 80%, reusable workflows that standardize pipelines across the organization, and upcoming parallel steps to shave another 40% off runtimes.
For .NET 10 teams, GitHub Actions is the natural pick: deep GitHub integration, a generous free tier, stronger and cheaper runners, and a massive action ecosystem. The most important rule: pin SHAs on every action, use OIDC for every cloud deploy.
Get started now
If you're on Azure DevOps Pipelines or Jenkins, migrating to GitHub Actions isn't as painful as you might think. GitHub provides an official migration guide for each CI system. First step: create a simple workflow that just runs dotnet test on each PR — then grow from there.
References:
Cloudflare Workers 2026 — Building Full-Stack on the Edge with Zero Cold Start and Zero Egress
AWS Lambda Serverless 2026: Architecture, SnapStart, Event-Driven Patterns, and the Production Free Tier
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.