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: distDeployment 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: distE2E 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-reportMatrix 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 testGitLab 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: manualAzure 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
| Use | What |
|---|---|
| API keys, passwords | Variable group (Library → Variable Groups), reference with $(NAME) and mark sensitive |
| Certificates, keystores, JSON service accounts | Secure files, downloaded with DownloadSecureFile@1 |
| External tools (Azure, Play Store, App Store Connect, npm registry) | Service connection (Project settings → Service connections) |
| KeyVault-backed secrets | Variable 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 → EnvironmentsConfigure 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
| Aspect | GitHub Actions | Azure DevOps |
|---|---|---|
| Pipeline language | YAML, simpler syntax | YAML, richer (stages, deployment jobs, templates) |
| Marketplace | Huge, community-led | Smaller, more enterprise-vetted |
| Secrets | Repo/org/environment secrets | Variable groups, secure files, Key Vault |
| Approvals | Environments with required reviewers | Environments with checks (richer) |
| Self-hosted agents | Easy to spin up | Standard, well-documented in enterprise |
| Work item linking | Issues/PRs | Boards work items (deep integration) |
| Best fit | OSS, modern stacks, GitHub-resident orgs | Enterprises 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 = 200Docker
# 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.comFeature 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.shDeployment 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
- Run tests and linting on every pull request
- Use caching to speed up builds
- Separate CI and CD workflows
- Use environment-specific configurations
- Implement health checks after deployment
- Set up automatic rollback on failures
- Monitor deployments and notify team
- Keep deployment times under 10 minutes