Steven's Knowledge

CI/CD

Continuous integration and deployment pipelines for frontend applications

CI/CD

Continuous Integration and Continuous Deployment automate testing, building, and deploying applications, ensuring consistent quality and fast delivery.

GitHub Actions

Basic Workflow

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  lint-and-test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup pnpm
        uses: pnpm/action-setup@v2
        with:
          version: 8

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Type check
        run: pnpm type-check

      - name: Lint
        run: pnpm lint

      - name: Test
        run: pnpm test -- --coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info

  build:
    runs-on: ubuntu-latest
    needs: lint-and-test

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup pnpm
        uses: pnpm/action-setup@v2
        with:
          version: 8

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build
        run: pnpm build

      - name: Upload artifacts
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist

Deployment Workflow

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup pnpm
        uses: pnpm/action-setup@v2
        with:
          version: 8

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build
        run: pnpm build
        env:
          VITE_API_URL: ${{ secrets.API_URL }}

      # Deploy to Vercel
      - name: Deploy to Vercel
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          vercel-args: '--prod'

      # Or deploy to Cloudflare Pages
      - name: Deploy to Cloudflare Pages
        uses: cloudflare/pages-action@v1
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          projectName: my-project
          directory: dist

E2E Testing Workflow

# .github/workflows/e2e.yml
name: E2E Tests

on:
  pull_request:
    branches: [main]

jobs:
  e2e:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup pnpm
        uses: pnpm/action-setup@v2
        with:
          version: 8

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Install Playwright browsers
        run: pnpm exec playwright install --with-deps

      - name: Build
        run: pnpm build

      - name: Run E2E tests
        run: pnpm test:e2e

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report

Matrix Testing

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]
        os: [ubuntu-latest, windows-latest, macos-latest]

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

GitLab CI

# .gitlab-ci.yml
stages:
  - test
  - build
  - deploy

variables:
  NODE_VERSION: "20"

.node-setup:
  image: node:${NODE_VERSION}
  cache:
    key: ${CI_COMMIT_REF_SLUG}
    paths:
      - node_modules/
  before_script:
    - corepack enable
    - pnpm install --frozen-lockfile

lint:
  extends: .node-setup
  stage: test
  script:
    - pnpm lint
    - pnpm type-check

test:
  extends: .node-setup
  stage: test
  script:
    - pnpm test -- --coverage
  coverage: '/Lines\s+:\s+(\d+.\d+)%/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml

build:
  extends: .node-setup
  stage: build
  script:
    - pnpm build
  artifacts:
    paths:
      - dist/
    expire_in: 1 week

deploy-staging:
  stage: deploy
  environment:
    name: staging
    url: https://staging.example.com
  script:
    - echo "Deploying to staging"
  only:
    - develop

deploy-production:
  stage: deploy
  environment:
    name: production
    url: https://example.com
  script:
    - echo "Deploying to production"
  only:
    - main
  when: manual

Azure DevOps (ADO) Pipelines

Common in enterprise telco/finance/government environments. Pipelines are defined in azure-pipelines.yml; agents run on Microsoft-hosted pools or self-hosted (often required for on-prem network access).

Basic Web Pipeline

# azure-pipelines.yml
trigger:
  branches:
    include: [main, develop]

pr:
  branches:
    include: [main]

variables:
  - group: web-app-secrets        # variable group from Library
  - name: nodeVersion
    value: '20.x'

stages:
  - stage: Validate
    jobs:
      - job: Lint_Test
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: $(nodeVersion)
            displayName: 'Use Node $(nodeVersion)'

          - task: Cache@2
            inputs:
              key: 'pnpm | "$(Agent.OS)" | pnpm-lock.yaml'
              path: $(Pipeline.Workspace)/.pnpm-store
            displayName: 'Cache pnpm store'

          - script: |
              corepack enable
              pnpm config set store-dir $(Pipeline.Workspace)/.pnpm-store
              pnpm install --frozen-lockfile
            displayName: 'Install dependencies'

          - script: pnpm type-check
            displayName: 'Type check'

          - script: pnpm lint
            displayName: 'Lint'

          - script: pnpm test --reporter=junit --outputFile=test-results.xml
            displayName: 'Unit tests'

          - task: PublishTestResults@2
            condition: succeededOrFailed()
            inputs:
              testResultsFiles: 'test-results.xml'
              testRunTitle: 'Unit tests'

          - task: PublishCodeCoverageResults@2
            condition: succeededOrFailed()
            inputs:
              summaryFileLocation: 'coverage/cobertura-coverage.xml'

  - stage: Build
    dependsOn: Validate
    jobs:
      - job: Build
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - script: pnpm install --frozen-lockfile
          - script: pnpm build
            env:
              VITE_API_URL: $(VITE_API_URL)
          - task: PublishPipelineArtifact@1
            inputs:
              targetPath: 'dist'
              artifact: 'web-build'

  - stage: Deploy_Dev
    dependsOn: Build
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/develop'))
    jobs:
      - deployment: Deploy
        environment: 'web-dev'    # creates approval gate via environment policies
        pool:
          vmImage: 'ubuntu-latest'
        strategy:
          runOnce:
            deploy:
              steps:
                - download: current
                  artifact: web-build
                - task: AzureStaticWebApp@0
                  inputs:
                    app_location: '$(Pipeline.Workspace)/web-build'
                    azure_static_web_apps_api_token: $(SWA_DEPLOYMENT_TOKEN)

React Native Mobile Pipeline

For mobile, the agent must be macOS for iOS and either macOS or Linux for Android.

# azure-pipelines-mobile.yml
trigger: none

parameters:
  - name: platform
    type: string
    default: both
    values: [ios, android, both]
  - name: lane
    type: string
    default: beta
    values: [beta, production]

stages:
  - stage: iOS
    condition: in('${{ parameters.platform }}', 'ios', 'both')
    jobs:
      - job: Build
        pool:
          vmImage: 'macos-14'
        steps:
          - task: NodeTool@0
            inputs: { versionSpec: '20.x' }

          - script: |
              corepack enable
              pnpm install --frozen-lockfile
            displayName: 'Install JS deps'

          - task: InstallAppleCertificate@2
            inputs:
              certSecureFile: 'distribution.p12'
              certPwd: $(P12_PASSWORD)

          - task: InstallAppleProvisioningProfile@1
            inputs:
              provisioningProfileLocation: 'secureFiles'
              provProfileSecureFile: 'OneNz_AppStore.mobileprovision'

          - script: |
              cd ios
              bundle install
              bundle exec pod install
            displayName: 'CocoaPods'

          - script: |
              cd ios
              bundle exec fastlane ${{ parameters.lane }}
            env:
              MATCH_PASSWORD: $(MATCH_PASSWORD)
              APP_STORE_CONNECT_API_KEY_CONTENT: $(APP_STORE_KEY)
            displayName: 'Fastlane ${{ parameters.lane }}'

  - stage: Android
    condition: in('${{ parameters.platform }}', 'android', 'both')
    jobs:
      - job: Build
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - task: JavaToolInstaller@0
            inputs:
              versionSpec: '17'
              jdkArchitectureOption: x64
              jdkSourceOption: 'PreInstalled'

          - task: NodeTool@0
            inputs: { versionSpec: '20.x' }

          - script: |
              corepack enable
              pnpm install --frozen-lockfile
            displayName: 'Install JS deps'

          - task: DownloadSecureFile@1
            name: keystore
            inputs:
              secureFile: 'release.keystore'

          - script: |
              cd android
              ./gradlew bundleRelease \
                -Pandroid.injected.signing.store.file=$(keystore.secureFilePath) \
                -Pandroid.injected.signing.store.password=$(KEYSTORE_PASSWORD) \
                -Pandroid.injected.signing.key.alias=$(KEY_ALIAS) \
                -Pandroid.injected.signing.key.password=$(KEY_PASSWORD)
            displayName: 'Build AAB'

          - task: GooglePlayRelease@4
            inputs:
              serviceConnection: 'play-console'
              applicationId: 'nz.one.app'
              action: 'SingleBundle'
              bundleFile: 'android/app/build/outputs/bundle/release/app-release.aab'
              track: ${{ parameters.lane }}

Flutter Pipeline

# azure-pipelines-flutter.yml
stages:
  - stage: Test
    jobs:
      - job: Analyze_Test
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - task: FlutterInstall@0
            inputs:
              channel: 'stable'
              version: 'custom'
              customVersion: '3.24.0'

          - script: flutter pub get
            displayName: 'Pub get'

          - script: dart format --output=none --set-exit-if-changed .
            displayName: 'Format check'

          - script: flutter analyze --fatal-infos
            displayName: 'Analyze'

          - script: flutter test --machine --coverage > test-results.json
            displayName: 'Test'

          - task: PublishTestResults@2
            inputs:
              testResultsFiles: 'test-results.json'

Variable Groups, Secure Files, Service Connections

UseWhat
API keys, passwordsVariable group (Library → Variable Groups), reference with $(NAME) and mark sensitive
Certificates, keystores, JSON service accountsSecure files, downloaded with DownloadSecureFile@1
External tools (Azure, Play Store, App Store Connect, npm registry)Service connection (Project settings → Service connections)
KeyVault-backed secretsVariable group linked to Key Vault — secrets pulled at run time

Never commit secrets to the repo; never echo them — Azure DevOps masks variables marked sensitive, but custom transforms (base64 encoding) can leak them.

Environments and Approvals

- deployment: Deploy
  environment: 'web-prod'   # configured in Pipelines → Environments

Configure approvers and checks on the environment (not in YAML) — keeps the gating policy auditable separately from the pipeline code.

Branch Policies (Repo)

In Repos → Branches → main → Branch policies, enforce:

  • Minimum reviewers (2).
  • Build validation: link to the PR pipeline.
  • Linked work items required.
  • Comment resolution required.

Without these, the YAML pipeline is advisory only.

Templates and Reuse

Avoid copy-paste across pipelines:

# templates/install-pnpm.yml
parameters:
  - name: nodeVersion
    default: '20.x'

steps:
  - task: NodeTool@0
    inputs:
      versionSpec: ${{ parameters.nodeVersion }}
  - script: |
      corepack enable
      pnpm install --frozen-lockfile
    displayName: 'Install dependencies'
# in another pipeline
steps:
  - template: templates/install-pnpm.yml
    parameters:
      nodeVersion: '22.x'

Self-Hosted Agents

When you need access to on-prem networks (artifact mirrors, internal package registries, telco APIs), Microsoft-hosted agents won't reach them. Run self-hosted agents inside the corporate network and reference them with pool: name: onenz-internal.

GitHub Actions vs Azure DevOps — Practical Differences

AspectGitHub ActionsAzure DevOps
Pipeline languageYAML, simpler syntaxYAML, richer (stages, deployment jobs, templates)
MarketplaceHuge, community-ledSmaller, more enterprise-vetted
SecretsRepo/org/environment secretsVariable groups, secure files, Key Vault
ApprovalsEnvironments with required reviewersEnvironments with checks (richer)
Self-hosted agentsEasy to spin upStandard, well-documented in enterprise
Work item linkingIssues/PRsBoards work items (deep integration)
Best fitOSS, modern stacks, GitHub-resident orgsEnterprises on Azure / on-prem / Boards

The pipeline concepts are mostly portable. Switching cost is mainly in secrets, agent setup, and approval workflows — not in the actual build/test/deploy logic.

Deployment Platforms

Vercel

// vercel.json
{
  "buildCommand": "pnpm build",
  "outputDirectory": "dist",
  "framework": "vite",
  "rewrites": [
    { "source": "/(.*)", "destination": "/index.html" }
  ],
  "headers": [
    {
      "source": "/assets/(.*)",
      "headers": [
        { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
      ]
    }
  ]
}

Cloudflare Pages

# wrangler.toml
name = "my-app"
compatibility_date = "2024-01-01"

[site]
bucket = "./dist"

[[redirects]]
from = "/*"
to = "/index.html"
status = 200

Docker

# Dockerfile
FROM node:20-alpine AS builder

WORKDIR /app
RUN corepack enable

COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

COPY . .
RUN pnpm build

FROM nginx:alpine AS runner

COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# nginx.conf
server {
    listen 80;
    server_name localhost;
    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    location /assets {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    gzip on;
    gzip_types text/plain text/css application/json application/javascript;
}

Environment Management

Environment Variables

# GitHub Actions
env:
  VITE_API_URL: ${{ vars.API_URL }}
  VITE_APP_KEY: ${{ secrets.APP_KEY }}

# Different environments
jobs:
  deploy-staging:
    environment: staging
    env:
      VITE_API_URL: https://api.staging.example.com

  deploy-production:
    environment: production
    env:
      VITE_API_URL: https://api.example.com

Feature Flags

// Feature flag configuration
const features = {
  newDashboard: process.env.VITE_FEATURE_NEW_DASHBOARD === 'true',
  betaFeatures: process.env.VITE_ENVIRONMENT !== 'production',
};

// Usage
if (features.newDashboard) {
  return <NewDashboard />;
}
return <LegacyDashboard />;

Monitoring and Rollback

Health Checks

deploy:
  steps:
    - name: Deploy
      run: ./deploy.sh

    - name: Health Check
      run: |
        for i in {1..10}; do
          if curl -s https://example.com/health | grep -q "ok"; then
            echo "Health check passed"
            exit 0
          fi
          sleep 10
        done
        echo "Health check failed"
        exit 1

    - name: Rollback on failure
      if: failure()
      run: ./rollback.sh

Deployment Notifications

- name: Notify Slack
  if: always()
  uses: slackapi/slack-github-action@v1
  with:
    payload: |
      {
        "text": "Deployment ${{ job.status }}: ${{ github.repository }}",
        "blocks": [
          {
            "type": "section",
            "text": {
              "type": "mrkdwn",
              "text": "*Deployment ${{ job.status }}*\nRepo: ${{ github.repository }}\nBranch: ${{ github.ref_name }}"
            }
          }
        ]
      }
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

Best Practices

CI/CD Guidelines

  1. Run tests and linting on every pull request
  2. Use caching to speed up builds
  3. Separate CI and CD workflows
  4. Use environment-specific configurations
  5. Implement health checks after deployment
  6. Set up automatic rollback on failures
  7. Monitor deployments and notify team
  8. Keep deployment times under 10 minutes

On this page