Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 PipelinesGitHub ActionsNotes
PipelineWorkflowDefined in .github/workflows/*.yml
StageJobJobs can depend on other jobs
JobJobSame concept
StepStepSame concept
TaskActionuses: actions/checkout@v4
Script steprun: stepInline shell commands
Agent poolRunnerruns-on: ubuntu-latest
Self-hosted agentSelf-hosted runnerSame concept, different setup
Variable groupRepository/Org secretsManaged in GitHub Settings
Service connectionSecret (token/key)No special object, just a secret
Pipeline variableenv: or vars:vars for non-sensitive, secrets for sensitive
Triggeron:Push, PR, schedule, manual
Conditionif:if: github.ref == 'refs/heads/main'
TemplateReusable workflow.github/workflows/ called with workflow_call
Artifactactions/upload-artifactUpload/download between jobs
Environment (deployment)environment:Approval gates, protection rules
Build numbergithub.run_numberAuto-incrementing integer
Agent capabilitiesRunner labelsruns-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

BehaviorAzure PipelinesGitHub Actions
File locationRoot: azure-pipelines.yml.github/workflows/*.yml
Parallel jobsStages sequential, jobs parallelAll jobs parallel unless needs:
Secret maskingAutomaticAutomatic
Approval gatesEnvironment checksEnvironment protection rules
Built-in tasks400+ Azure tasksCommunity marketplace + built-ins
YAML anchorsSupportedNot supported — use reusable workflows
Skip CI[skip ci] in commit message[skip ci] or [no ci] in commit
Max job timeout360 minutes360 minutes (6 hours)
Concurrency controlNo built-inconcurrency: key
Docker service containersservices: blockservices: 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

Further Reading