GitHub Actions: From Azure Pipelines to Actions
For .NET engineers who know: Azure Pipelines YAML, build agents, variable groups, service connections, multi-stage pipelines You’ll learn: How GitHub Actions maps to every Azure Pipelines concept and how to build a complete CI/CD workflow for a Node.js/TypeScript project Time: 15-20 minutes
The .NET Way (What You Already Know)
A typical Azure Pipelines YAML for a .NET app:
# azure-pipelines.yml
trigger:
branches:
include:
- main
pool:
vmImage: 'ubuntu-latest'
variables:
- group: production-secrets
stages:
- stage: Build
jobs:
- job: BuildJob
steps:
- task: UseDotNet@2
inputs:
version: '8.x'
- script: dotnet restore
- script: dotnet build --no-restore
- task: DotNetCoreCLI@2
inputs:
command: 'test'
- stage: Deploy
dependsOn: Build
condition: succeeded()
jobs:
- deployment: DeployProd
environment: production
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1
inputs:
azureSubscription: 'MyServiceConnection'
appName: 'my-app'
GitHub Actions serves the same purpose with a different YAML dialect and different primitives — but the mental model translates almost 1:1.
The GitHub Actions Way
Concept Mapping
| Azure Pipelines | GitHub Actions | Notes |
|---|---|---|
| Pipeline | Workflow | Defined in .github/workflows/*.yml |
| Stage | Job | Jobs can depend on other jobs |
| Job | Job | Same concept |
| Step | Step | Same concept |
| Task | Action | uses: actions/checkout@v4 |
| Script step | run: step | Inline shell commands |
| Agent pool | Runner | runs-on: ubuntu-latest |
| Self-hosted agent | Self-hosted runner | Same concept, different setup |
| Variable group | Repository/Org secrets | Managed in GitHub Settings |
| Service connection | Secret (token/key) | No special object, just a secret |
| Pipeline variable | env: or vars: | vars for non-sensitive, secrets for sensitive |
| Trigger | on: | Push, PR, schedule, manual |
| Condition | if: | if: github.ref == 'refs/heads/main' |
| Template | Reusable workflow | .github/workflows/ called with workflow_call |
| Artifact | actions/upload-artifact | Upload/download between jobs |
| Environment (deployment) | environment: | Approval gates, protection rules |
| Build number | github.run_number | Auto-incrementing integer |
| Agent capabilities | Runner labels | runs-on: [self-hosted, linux, x64] |
Workflow File Structure
A GitHub Actions workflow lives in .github/workflows/:
.github/
workflows/
ci.yml # Runs on every PR and push to main
cd.yml # Deploys after CI passes on main
scheduled.yml # Nightly jobs, cron tasks
release.yml # Triggered by tag push
The basic structure:
name: CI # Displayed in GitHub UI
on: # Triggers (equivalent to "trigger:" in ADO)
push:
branches: [main]
pull_request:
branches: [main]
env: # Workflow-level environment variables
NODE_VERSION: '20'
jobs:
build: # Job ID (used in "needs:" references)
name: Build and Test # Display name
runs-on: ubuntu-latest # Runner
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
Triggers
on:
# Push to specific branches
push:
branches: [main, 'release/**']
paths:
- 'src/**'
- 'package*.json'
paths-ignore:
- '**.md'
# PRs targeting specific branches
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
# Scheduled (cron syntax)
schedule:
- cron: '0 2 * * 1' # 2am every Monday UTC
# Manual trigger with inputs
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
default: 'staging'
type: choice
options: [staging, production]
# Called by another workflow
workflow_call:
inputs:
node-version:
type: string
default: '20'
secrets:
DATABASE_URL:
required: true
Job Dependencies
Jobs run in parallel by default. Use needs: to create dependencies — equivalent to Azure Pipelines stage dependsOn:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run lint
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run typecheck
test:
runs-on: ubuntu-latest
needs: [lint, typecheck] # Waits for both to pass
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
build:
runs-on: ubuntu-latest
needs: test # Waits for test to pass
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
deploy:
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main' # Only on main branch
environment: production # Requires environment approval
steps:
- name: Deploy
run: echo "Deploying..."
Secrets and Variables
Non-sensitive config — use repository Variables (Settings > Secrets and Variables > Variables):
env:
APP_URL: ${{ vars.APP_URL }}
NODE_ENV: production
Sensitive values — use Secrets (Settings > Secrets and Variables > Secrets):
steps:
- name: Deploy to Render
env:
RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }}
run: |
curl -X POST \
-H "Authorization: Bearer $RENDER_API_KEY" \
https://api.render.com/v1/services/srv-xxx/deploys
Organization-level secrets are available to all repos in an org — equivalent to a shared ADO variable group. Set them in Organization Settings.
Environment secrets are scoped to a specific deployment environment:
jobs:
deploy:
environment: production # Unlocks environment-level secrets
steps:
- name: Migrate DB
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }} # From "production" env
run: npm run db:migrate
Caching node_modules
This is one of the most important optimizations. Without caching, npm ci runs a full install every time — slow and costly:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # Built-in cache for npm lockfile
- run: npm ci # Uses cache if lockfile unchanged
For pnpm (which we prefer):
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
Manual cache control for monorepos or unusual setups:
- uses: actions/cache@v4
with:
path: |
~/.pnpm-store
node_modules
packages/*/node_modules
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
Matrix Builds
Run the same job across multiple Node versions or operating systems — equivalent to multi-configuration builds in Azure Pipelines:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: ['18', '20', '22']
fail-fast: false # Don't cancel all if one fails
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
Matrix with exclusions:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: ['18', '20']
exclude:
- os: windows-latest
node: '18' # Skip this combination
Artifacts: Passing Build Output Between Jobs
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 7
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy
run: ./scripts/deploy.sh
Reusable Workflows
Equivalent to Azure Pipelines templates. Extract shared logic into a callable workflow:
# .github/workflows/_shared-test.yml
name: Shared Test Job
on:
workflow_call:
inputs:
node-version:
type: string
default: '20'
secrets:
DATABASE_URL:
required: true
jobs:
test:
runs-on: ubuntu-latest
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- run: npm ci
- run: npm test
Call it from another workflow:
# .github/workflows/ci.yml
jobs:
test:
uses: ./.github/workflows/_shared-test.yml
with:
node-version: '20'
secrets:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
Self-Hosted Runners
When you need specific hardware, network access, or to avoid GitHub’s runner costs:
jobs:
deploy:
runs-on: [self-hosted, linux, production] # Match runner labels
steps:
- run: echo "Running on our own machine"
Register a runner in GitHub: Settings > Actions > Runners > New self-hosted runner. Follow the setup script. The runner runs as a service on any Linux/macOS/Windows machine.
When to use self-hosted:
- Need access to private network resources (databases, internal services)
- Need specific hardware (GPU, high memory)
- High volume and GitHub’s per-minute costs add up
- Compliance requires builds not leaving your infrastructure
Complete CI/CD Workflow
Here is a production-ready workflow for a Node.js/TypeScript project:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true # Cancel previous run if new push arrives
env:
NODE_VERSION: '20'
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm run typecheck
test:
name: Test
runs-on: ubuntu-latest
needs: [lint, typecheck]
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm run db:migrate
- run: pnpm test --coverage
- name: Upload coverage
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage
path: coverage/
build:
name: Build
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 3
deploy:
name: Deploy to Production
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment:
name: production
url: https://myapp.example.com
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- name: Deploy to Render
env:
RENDER_DEPLOY_HOOK: ${{ secrets.RENDER_DEPLOY_HOOK }}
run: |
curl -X POST "$RENDER_DEPLOY_HOOK"
Expressions and Contexts
GitHub Actions has a template expression language for conditionals and dynamic values:
# Context variables
${{ github.sha }} # Commit SHA
${{ github.ref }} # refs/heads/main
${{ github.actor }} # Username who triggered
${{ github.event_name }} # push, pull_request, etc.
${{ runner.os }} # Linux, Windows, macOS
${{ job.status }} # success, failure, cancelled
# Functions
${{ hashFiles('**/package-lock.json') }}
${{ contains(github.ref, 'main') }}
${{ startsWith(github.ref, 'refs/tags/') }}
${{ format('Hello {0}', github.actor) }}
# Conditionals on steps
if: github.ref == 'refs/heads/main'
if: failure() # Run only if previous step failed
if: always() # Always run (like finally)
if: success() && github.event_name == 'push'
if: contains(github.event.pull_request.labels.*.name, 'deploy-preview')
Key Differences
| Behavior | Azure Pipelines | GitHub Actions |
|---|---|---|
| File location | Root: azure-pipelines.yml | .github/workflows/*.yml |
| Parallel jobs | Stages sequential, jobs parallel | All jobs parallel unless needs: |
| Secret masking | Automatic | Automatic |
| Approval gates | Environment checks | Environment protection rules |
| Built-in tasks | 400+ Azure tasks | Community marketplace + built-ins |
| YAML anchors | Supported | Not supported — use reusable workflows |
| Skip CI | [skip ci] in commit message | [skip ci] or [no ci] in commit |
| Max job timeout | 360 minutes | 360 minutes (6 hours) |
| Concurrency control | No built-in | concurrency: key |
| Docker service containers | services: block | services: block (same syntax) |
Gotchas for .NET Engineers
Gotcha 1: Jobs start fresh with no shared filesystem.
Each job runs in a completely isolated environment. Files written in one job are not available in another unless you use actions/upload-artifact and actions/download-artifact. This trips up .NET engineers who expect the workspace to persist across stages like it does in Azure Pipelines agents that reuse workspaces.
Gotcha 2: Secrets are not available in pull requests from forks.
For security reasons, GitHub does not expose secrets.* to workflows triggered by PRs from forked repositories. If you see empty secret values in a fork-triggered run, this is why. Use the pull_request_target event with extreme caution — it runs with repo access — or design your CI to not need secrets for basic checks.
Gotcha 3: concurrency: is critical for deployments.
Without concurrency: limits, pushing twice quickly can result in two concurrent deployments. The second deploy might finish before the first, leaving an old build in production. Always set concurrency: on deploy jobs with cancel-in-progress: false (cancel CI runs, but never cancel a deploy mid-flight).
concurrency:
group: deploy-production
cancel-in-progress: false # Never interrupt a running deploy
Gotcha 4: Service containers need health checks or they’ll fail silently.
When running a Postgres service container for tests, the container starts before Postgres is ready to accept connections. Without the options: --health-cmd pg_isready block, your migration step will fail with a connection error. Always add health check options to service containers.
Gotcha 5: actions/cache hits don’t guarantee fresh content.
Cache keys are based on a hash (e.g., hashFiles('**/pnpm-lock.yaml')). If the lockfile hasn’t changed, the cache is used — which means if a package was published with a bug and you need to force a clean install, you must either change the lockfile or bust the cache by changing the cache key prefix.
Gotcha 6: GitHub-hosted runners are ephemeral — no tool persistence. Azure DevOps agents can accumulate tools between runs if you manage the agent pool. GitHub-hosted runners are torn down after every job. Every tool installation must be in the workflow. This is actually better for reproducibility, but it means you cannot rely on anything pre-installed beyond what’s documented in the runner image manifest.
Hands-On Exercise
Create a working CI workflow for a minimal TypeScript project:
mkdir actions-practice && cd actions-practice
git init
mkdir -p .github/workflows src
# Create a minimal TypeScript setup
cat > package.json << 'EOF'
{
"name": "actions-practice",
"version": "1.0.0",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "node --test",
"lint": "echo 'lint passed'"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}
EOF
cat > tsconfig.json << 'EOF'
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "dist",
"strict": true
},
"include": ["src/**/*"]
}
EOF
cat > src/greet.ts << 'EOF'
export const greet = (name: string): string => `Hello, ${name}`;
EOF
cat > src/greet.test.ts << 'EOF'
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import { greet } from './greet.js';
test('greet returns greeting', () => {
assert.equal(greet('World'), 'Hello, World');
});
EOF
Now create the workflow:
cat > .github/workflows/ci.yml << 'EOF'
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm run build
- run: npm test
EOF
git add .
git commit -m "ci: add GitHub Actions workflow"
Push to GitHub and watch the Actions tab:
gh repo create actions-practice --private --source=. --push
gh run watch # Watch the workflow in real time
Quick Reference
# Trigger patterns
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 9 * * 1-5'
workflow_dispatch:
# Job with dependencies
jobs:
my-job:
runs-on: ubuntu-latest
needs: [other-job]
if: github.ref == 'refs/heads/main'
environment: production
steps:
- uses: actions/checkout@v4
- run: echo "hello"
# Secrets and variables
env:
MY_SECRET: ${{ secrets.MY_SECRET }}
MY_VAR: ${{ vars.MY_VAR }}
# Cache pnpm
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
# Artifacts
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
# Postgres service container
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-retries 5
# Cancel in-progress
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true