ci-cd-ops
CI/CD pipeline patterns with GitHub Actions, release automation, and testing strategies. Use for: github actions, workflow, CI, CD, pipeline, deploy, release, semantic release, changesets, goreleaser, matrix, cache, secrets, environment, artifact, reusable workflow, composite act
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/ci-cd-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
git clone https://github.com/0xDarkMatter/claude-mods.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
CI/CD Operations
Comprehensive patterns for continuous integration, delivery, and deployment using GitHub Actions, release automation tools, and testing pipelines.
GitHub Actions Quick Reference
Workflow File Anatomy
name: CI # Display name in Actions tab
on: # Trigger events
push:
branches: [main]
pull_request:
branches: [main]
permissions: # GITHUB_TOKEN scope (least privilege)
contents: read
pull-requests: write
concurrency: # Prevent duplicate runs
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env: # Workflow-level environment variables
NODE_VERSION: "20"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- run: npm ci
- run: npm test
Core Syntax Elements
| Element | Purpose | Example |
|---|---|---|
on |
Event triggers | push, pull_request, schedule |
jobs.<id>.runs-on |
Runner selection | ubuntu-latest, self-hosted |
jobs.<id>.needs |
Job dependencies | needs: [build, lint] |
jobs.<id>.if |
Conditional execution | if: github.event_name == 'push' |
jobs.<id>.strategy.matrix |
Parallel variants | node-version: [18, 20, 22] |
jobs.<id>.environment |
Deployment target | environment: production |
jobs.<id>.permissions |
Token scope | contents: write |
steps[*].uses |
Use an action | uses: actions/checkout@v4 |
steps[*].run |
Run a command | run: npm test |
steps[*].env |
Step environment | env: { CI: true } |
Trigger Decision Tree
| Scenario | Trigger | Config |
|---|---|---|
| Run tests on every PR | pull_request |
branches: [main] |
| Deploy on merge to main | push |
branches: [main] |
| Release on version tag | push |
tags: ['v*'] |
| Nightly builds | schedule |
cron: '0 2 * * *' |
| Manual deployment | workflow_dispatch |
inputs: { environment: ... } |
| Called by another workflow | workflow_call |
inputs:, secrets: |
| On PR label change | pull_request |
types: [labeled] |
| On issue comment | issue_comment |
types: [created] |
| On release published | release |
types: [published] |
| On package push | registry_package |
types: [published] |
Trigger Filter Patterns
on:
push:
branches: [main, 'release/**'] # Branch patterns
paths: ['src/**', '!src/**/*.test.*'] # Path filters (ignore tests)
tags: ['v*'] # Tag patterns
pull_request:
types: [opened, synchronize, reopened] # Default types
paths-ignore: ['docs/**', '*.md'] # Ignore docs-only changes
Caching Strategies
| Ecosystem | Action / Key | Path | Restore Key |
|---|---|---|---|
| Node (npm) | actions/setup-node with cache: npm |
Auto | Auto |
| Node (pnpm) | actions/setup-node with cache: pnpm |
Auto | Auto |
| Go modules | actions/setup-go with cache: true |
Auto | Auto |
| Cargo | actions/cache@v4 |
~/.cargo/registry, target |
cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} |
| pip / uv | actions/setup-python with cache: pip |
Auto | Auto |
| Docker layers | docker/build-push-action |
Uses buildx cache | type=gha or type=registry |
| Gradle | actions/setup-java with cache: gradle |
Auto | Auto |
| Composer | actions/cache@v4 |
vendor |
composer-${{ hashFiles('composer.lock') }} |
Manual Cache Example
- uses: actions/cache@v4
with:
path: |
~/.cargo/bin
~/.cargo/registry
~/.cargo/git
target
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
cargo-${{ runner.os }}-
Matrix Strategy
strategy:
fail-fast: false # Don't cancel siblings on failure
max-parallel: 4 # Limit concurrent jobs
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
include: # Add specific combos
- os: ubuntu-latest
node-version: 22
coverage: true
exclude: # Remove specific combos
- os: windows-latest
node-version: 18
Dynamic Matrix
prepare:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set.outputs.matrix }}
steps:
- id: set
run: echo "matrix=$(jq -c . matrix.json)" >> "$GITHUB_OUTPUT"
test:
needs: prepare
strategy:
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
Secrets Management
| Scope | Access | Use Case |
|---|---|---|
| Repository secrets | All workflows in repo | API keys, tokens |
| Environment secrets | Jobs targeting that environment | Production credentials |
| Organization secrets | Selected repos in org | Shared service accounts |
| OIDC tokens | Federated identity | Cloud deployment (no stored secrets) |
Secrets Best Practices
# Reference secrets - NEVER echo or log them
- run: deploy --token ${{ secrets.DEPLOY_TOKEN }}
# Mask custom values
- run: echo "::add-mask::$CUSTOM_SECRET"
# Use environments for deployment secrets
jobs:
deploy:
environment: production # Requires approval + has secrets
steps:
- run: deploy --key ${{ secrets.PROD_API_KEY }}
OIDC for Cloud (No Stored Secrets)
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions
aws-region: us-east-1
Common Workflow Patterns
Test on Pull Request
name: Test
on:
pull_request:
branches: [main]
concurrency:
group: test-${{ github.head_ref }}
cancel-in-progress: true
jobs:
test:
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 test -- --coverage
Deploy on Merge to Main
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- run: npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
Release on Tag
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: |
gh release create ${{ github.ref_name }} \
--generate-notes \
--title "${{ github.ref_name }}"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Gotchas Table
| Gotcha | Problem | Fix |
|---|---|---|
| Shallow clone | git describe fails, history missing |
actions/checkout@v4 with fetch-depth: 0 |
| Default permissions | GITHUB_TOKEN is read-only by default |
Set permissions: explicitly |
| Action pinning | @main can break without warning |
Pin to SHA: @abc123 or @v4 |
| Fork PR secrets | Secrets unavailable on fork PRs | Use pull_request_target carefully |
| Concurrent deploys | Race condition on production | Use concurrency: groups |
| Stale caches | Cache grows unbounded | Include lockfile hash in key |
| Node.js version | setup-node defaults vary |
Always specify node-version |
| Docker layer cache | Rebuilds everything without cache | Use cache-from: type=gha |
| Matrix + environment | Each matrix job needs approval | Use a single deploy job after matrix |
| Path filters + required checks | Skipped jobs block merge | Use paths-filter action or make checks non-required |
GITHUB_TOKEN in PRs |
Cannot trigger other workflows | Use a PAT or GitHub App token |
| Windows line endings | Scripts fail with \r\n |
Use .gitattributes or core.autocrlf |
Expression Syntax Quick Reference
| Expression | Result |
|---|---|
${{ github.event_name }} |
push, pull_request, etc. |
${{ github.ref_name }} |
Branch or tag name |
${{ github.sha }} |
Full commit SHA |
${{ github.actor }} |
User who triggered |
${{ runner.os }} |
Linux, Windows, macOS |
${{ contains(github.event.head_commit.message, '[skip ci]') }} |
Check commit message |
${{ needs.build.outputs.version }} |
Output from prior job |
${{ fromJson(steps.meta.outputs.json) }} |
Parse JSON output |
${{ hashFiles('**/package-lock.json') }} |
Hash for cache keys |
${{ format('refs/heads/{0}', matrix.branch) }} |
String formatting |
${{ toJson(matrix) }} |
Debug: print matrix config |
Step Outputs
steps:
- id: version
run: echo "value=$(cat VERSION)" >> "$GITHUB_OUTPUT"
- run: echo "Version is ${{ steps.version.outputs.value }}"
Job Outputs (for Cross-Job Communication)
jobs:
build:
runs-on: ubuntu-latest
outputs:
artifact-id: ${{ steps.upload.outputs.artifact-id }}
steps:
- id: upload
run: echo "artifact-id=abc123" >> "$GITHUB_OUTPUT"
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Deploying ${{ needs.build.outputs.artifact-id }}"
Reference Files
| File | Contents |
|---|---|
references/github-actions.md |
Complete workflow syntax, reusable workflows, composite actions, OIDC, runners, debugging |
references/release-automation.md |
Semantic versioning, semantic-release, changesets, goreleaser, changelog, publishing |
references/testing-pipelines.md |
Test stages, parallelism, coverage, service containers, e2e in CI, deployment pipelines |
Files (claude-mods)
-
assets
-
.gitkeep 0 B · in bundle
-
-
references
-
github-actions.md 17 KB
# GitHub Actions Reference ## Table of Contents - [Workflow File Anatomy](#workflow-file-anatomy) - [Job Dependencies and Conditionals](#job-dependencies-and-conditionals) - [Reusable Workflows](#reusable-workflows) - [Composite Actions](#composite-actions) - [Matrix Strategy](#matrix-strategy) - [Artifacts](#artifacts) - [Environment Protection Rules](#environment-protection-rules) - [Concurrency Control](#concurrency-control) - [Self-Hosted Runners](#self-hosted-runners) - [OIDC for Cloud Deployment](#oidc-for-cloud-deployment) - [Common Action Recipes](#common-action-recipes) - [Debugging Workflows](#debugging-workflows) --- ## Workflow File Anatomy Every workflow lives in `.github/workflows/*.yml`. A complete annotated example: ```yaml # .github/workflows/ci.yml name: CI Pipeline # Name shown in Actions tab # ── Triggers ────────────────────────────────────────────── on: push: branches: [main, 'release/**'] paths-ignore: ['docs/**', '*.md'] pull_request: branches: [main] types: [opened, synchronize, reopened] schedule: - cron: '0 6 * * 1' # Weekly Monday 6am UTC workflow_dispatch: # Manual trigger inputs: environment: description: 'Deploy target' required: true default: 'staging' type: choice options: [staging, production] # ── Token Permissions (least privilege) ─────────────────── permissions: contents: read pull-requests: write checks: write # ── Concurrency ────────────────────────────────────────── concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} # ── Workflow Environment ───────────────────────────────── env: CI: true NODE_ENV: test # ── Jobs ───────────────────────────────────────────────── jobs: lint: name: Lint & Format runs-on: ubuntu-latest timeout-minutes: 10 # Prevent hung jobs steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' cache: npm - run: npm ci - run: npm run lint - run: npm run format:check test: name: Test (${{ matrix.node-version }}) runs-on: ubuntu-latest timeout-minutes: 15 needs: lint # Run after lint passes strategy: fail-fast: false matrix: node-version: [18, 20, 22] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: npm - run: npm ci - run: npm test -- --coverage - uses: actions/upload-artifact@v4 if: always() # Upload even on failure with: name: coverage-${{ matrix.node-version }} path: coverage/ retention-days: 7 deploy: name: Deploy runs-on: ubuntu-latest needs: test if: github.ref == 'refs/heads/main' && github.event_name == 'push' environment: production # Requires approval steps: - uses: actions/checkout@v4 - run: ./deploy.sh env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} ``` ## Job Dependencies and Conditionals ### Job Dependencies with `needs` ```yaml jobs: build: runs-on: ubuntu-latest steps: [...] test: needs: build # Waits for build runs-on: ubuntu-latest steps: [...] deploy: needs: [build, test] # Waits for both runs-on: ubuntu-latest steps: [...] ``` ### Conditional Execution with `if` ```yaml jobs: deploy: if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest notify: needs: deploy if: always() # Run even if deploy fails runs-on: ubuntu-latest release: if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest steps: - run: echo "Only on failure" if: failure() - run: echo "Only on success" if: success() - run: echo "Always run (cleanup)" if: always() - run: echo "Skip on forks" if: github.repository == 'owner/repo' - run: echo "Only for specific actor" if: github.actor == 'dependabot[bot]' - run: echo "Check PR label" if: contains(github.event.pull_request.labels.*.name, 'deploy') ``` ### Accessing Outputs from `needs` ```yaml jobs: check: runs-on: ubuntu-latest outputs: should-deploy: ${{ steps.decision.outputs.deploy }} steps: - id: decision run: | if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then echo "deploy=true" >> "$GITHUB_OUTPUT" else echo "deploy=false" >> "$GITHUB_OUTPUT" fi deploy: needs: check if: needs.check.outputs.should-deploy == 'true' runs-on: ubuntu-latest steps: - run: echo "Deploying..." ``` ## Reusable Workflows ### Defining a Reusable Workflow ```yaml # .github/workflows/reusable-test.yml name: Reusable Test Workflow on: workflow_call: inputs: node-version: description: 'Node.js version' required: false default: '20' type: string working-directory: description: 'Directory to run tests in' required: false default: '.' type: string secrets: NPM_TOKEN: required: false description: 'NPM auth token' outputs: coverage-percent: description: 'Test coverage percentage' value: ${{ jobs.test.outputs.coverage }} jobs: test: runs-on: ubuntu-latest outputs: coverage: ${{ steps.cov.outputs.percent }} defaults: run: working-directory: ${{ inputs.working-directory }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ inputs.node-version }} cache: npm - run: npm ci env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: npm test -- --coverage - id: cov run: | PERCENT=$(jq '.total.lines.pct' coverage/coverage-summary.json) echo "percent=$PERCENT" >> "$GITHUB_OUTPUT" ``` ### Calling a Reusable Workflow ```yaml # .github/workflows/ci.yml name: CI on: [push, pull_request] jobs: test: uses: ./.github/workflows/reusable-test.yml with: node-version: '20' secrets: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} # Or inherit all secrets test-inherit: uses: ./.github/workflows/reusable-test.yml secrets: inherit # Call from another repo test-external: uses: org/shared-workflows/.github/workflows/test.yml@main with: node-version: '20' report: needs: test runs-on: ubuntu-latest steps: - run: echo "Coverage was ${{ needs.test.outputs.coverage-percent }}%" ``` ## Composite Actions ### Creating a Composite Action ```yaml # .github/actions/setup-project/action.yml name: 'Setup Project' description: 'Install dependencies and build' inputs: node-version: description: 'Node.js version' required: false default: '20' install-command: description: 'Install command' required: false default: 'npm ci' outputs: cache-hit: description: 'Whether cache was hit' value: ${{ steps.cache.outputs.cache-hit }} runs: using: composite steps: - uses: actions/setup-node@v4 with: node-version: ${{ inputs.node-version }} - id: cache uses: actions/cache@v4 with: path: node_modules key: node-${{ runner.os }}-${{ hashFiles('package-lock.json') }} - if: steps.cache.outputs.cache-hit != 'true' run: ${{ inputs.install-command }} shell: bash - run: npm run build shell: bash # shell: is REQUIRED in composite ``` ### Using a Composite Action ```yaml steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-project with: node-version: '22' - run: npm test ``` ## Matrix Strategy ### Basic Matrix ```yaml strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] node: [18, 20, 22] # Creates 3 x 3 = 9 jobs ``` ### Include and Exclude ```yaml strategy: matrix: os: [ubuntu-latest, windows-latest] node: [18, 20] include: # Add a job with extra variables - os: ubuntu-latest node: 22 experimental: true # Add variables to existing combo - os: windows-latest node: 20 npm-version: 10 exclude: # Remove a specific combo - os: windows-latest node: 18 ``` ### Matrix with `continue-on-error` ```yaml strategy: fail-fast: false matrix: node: [18, 20, 22] include: - node: 22 experimental: true jobs: test: continue-on-error: ${{ matrix.experimental || false }} ``` ### Single-Dimension Matrix (List of Configs) ```yaml strategy: matrix: include: - name: Unit Tests command: npm run test:unit - name: Integration Tests command: npm run test:integration timeout: 30 - name: E2E Tests command: npm run test:e2e timeout: 60 ``` ## Artifacts ### Upload and Download Between Jobs ```yaml jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci && npm run build - uses: actions/upload-artifact@v4 with: name: dist path: dist/ retention-days: 1 # Short-lived build artifacts if-no-files-found: error # Fail if nothing to upload deploy: needs: build runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v4 with: name: dist path: dist/ - run: ls -la dist/ # Verify download ``` ### Multiple Artifact Upload (Matrix) ```yaml # Upload with unique names per matrix - uses: actions/upload-artifact@v4 with: name: results-${{ matrix.os }}-${{ matrix.node }} path: test-results/ # Download all in a later job - uses: actions/download-artifact@v4 with: pattern: results-* merge-multiple: true path: all-results/ ``` ## Environment Protection Rules Environments provide deployment gates and scoped secrets. ### Setting Up Environments Environments are configured in **Settings > Environments** on GitHub. Options: | Setting | Purpose | |---------|---------| | Required reviewers | Manual approval before deployment (up to 6 reviewers) | | Wait timer | Delay in minutes before deployment proceeds | | Deployment branches | Restrict which branches can deploy (e.g., only `main`) | | Environment secrets | Secrets scoped to this environment only | | Environment variables | Variables scoped to this environment | ### Using Environments in Workflows ```yaml jobs: deploy-staging: runs-on: ubuntu-latest environment: name: staging url: https://staging.example.com # Shown in deployment status steps: - run: deploy --env staging env: API_KEY: ${{ secrets.API_KEY }} # Environment-scoped secret deploy-production: needs: deploy-staging runs-on: ubuntu-latest environment: name: production url: https://example.com steps: - run: deploy --env production ``` ## Concurrency Control ### Cancel Previous Runs on Same Branch ```yaml concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true ``` ### Deployment Queue (No Cancellation) ```yaml concurrency: group: deploy-production cancel-in-progress: false # Queue instead of cancel ``` ### Per-PR Concurrency ```yaml concurrency: group: pr-${{ github.event.pull_request.number }} cancel-in-progress: true ``` ## Self-Hosted Runners ### Runner Labels ```yaml jobs: build: runs-on: [self-hosted, linux, x64, gpu] # Match all labels ``` ### Runner Groups (Enterprise/Org) ```yaml jobs: build: runs-on: group: production-runners labels: [linux, x64] ``` ### Hybrid Strategy ```yaml strategy: matrix: runner: [ubuntu-latest, self-hosted] jobs: test: runs-on: ${{ matrix.runner }} ``` ## OIDC for Cloud Deployment OIDC eliminates stored cloud credentials. GitHub issues a short-lived JWT that your cloud provider trusts. ### AWS ```yaml permissions: id-token: write contents: read steps: - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/GitHubActions aws-region: us-east-1 # No access keys needed - run: aws s3 sync dist/ s3://my-bucket ``` ### GCP ```yaml permissions: id-token: write contents: read steps: - uses: google-github-actions/auth@v2 with: workload_identity_provider: 'projects/123/locations/global/workloadIdentityPools/github/providers/my-repo' service_account: 'deploy@my-project.iam.gserviceaccount.com' - uses: google-github-actions/setup-gcloud@v2 - run: gcloud run deploy my-service --image gcr.io/my-project/app ``` ### Azure ```yaml permissions: id-token: write contents: read steps: - uses: azure/login@v2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - run: az webapp deploy --name my-app --src-path dist/ ``` ## Common Action Recipes ### Checkout ```yaml # Standard checkout - uses: actions/checkout@v4 # Full history (for changelogs, git describe) - uses: actions/checkout@v4 with: fetch-depth: 0 # Checkout PR head (for pull_request_target) - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} # Checkout with submodules - uses: actions/checkout@v4 with: submodules: recursive token: ${{ secrets.PAT }} # For private submodules ``` ### Setup Node.js ```yaml - uses: actions/setup-node@v4 with: node-version: 20 cache: npm # Or pnpm, yarn registry-url: https://npm.pkg.github.com ``` ### Setup Go ```yaml - uses: actions/setup-go@v5 with: go-version-file: go.mod # Read from go.mod cache: true # Cache go modules ``` ### Setup Python ```yaml - uses: actions/setup-python@v5 with: python-version: '3.12' cache: pip # Or pipenv, poetry ``` ### Docker Build and Push ```yaml - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/metadata-action@v5 id: meta with: images: ghcr.io/${{ github.repository }} tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha,prefix= type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max platforms: linux/amd64,linux/arm64 ``` ## Debugging Workflows ### Enable Debug Logging Set repository secret `ACTIONS_STEP_DEBUG` to `true` for verbose step output. Or re-run a failed job with "Enable debug logging" checkbox. ### Debug Expressions ```yaml - run: | echo "Event: ${{ github.event_name }}" echo "Ref: ${{ github.ref }}" echo "SHA: ${{ github.sha }}" echo "Actor: ${{ github.actor }}" echo "Matrix: ${{ toJson(matrix) }}" echo "Env: ${{ toJson(env) }}" # Dump full event payload - run: cat "$GITHUB_EVENT_PATH" | jq . ``` ### Local Testing with `act` ```bash # Install act (https://github.com/nektos/act) brew install act # macOS choco install act-cli # Windows # Run default event (push) act # Run specific workflow act -W .github/workflows/ci.yml # Run specific job act -j test # Run with specific event act pull_request # Pass secrets act -s GITHUB_TOKEN="$(gh auth token)" # Use specific runner image act -P ubuntu-latest=catthehacker/ubuntu:act-latest # Dry run (show what would run) act -n ``` ### Common Debugging Patterns ```yaml # Temporarily add to any step - run: | echo "::group::Debug Info" env | sort echo "::endgroup::" # Check file existence - run: | echo "::group::Workspace Contents" find . -maxdepth 3 -type f | head -50 echo "::endgroup::" # Conditional debug step - if: runner.debug == '1' run: | echo "Debug mode enabled" cat package.json | jq '.scripts' ``` ### Workflow Run Annotations ```yaml # Warning annotation - run: echo "::warning file=app.js,line=1::Missing error handling" # Error annotation - run: echo "::error file=app.js,line=10,col=5::Syntax error" # Notice annotation - run: echo "::notice::Deployment complete" # Group log lines - run: | echo "::group::Install Dependencies" npm ci echo "::endgroup::" ``` -
release-automation.md 14 KB
# Release Automation Reference ## Table of Contents - [Semantic Versioning](#semantic-versioning) - [Conventional Commits](#conventional-commits) - [Tool Comparison](#tool-comparison) - [semantic-release](#semantic-release) - [changesets](#changesets) - [release-please](#release-please) - [goreleaser](#goreleaser) - [Changelog Generation](#changelog-generation) - [GitHub Releases](#github-releases) - [NPM Publishing](#npm-publishing) - [Docker Image Tagging](#docker-image-tagging) - [Monorepo Release Strategies](#monorepo-release-strategies) --- ## Semantic Versioning Format: `MAJOR.MINOR.PATCH` (e.g., `2.4.1`) | Increment | When | Example | |-----------|------|---------| | MAJOR | Breaking API changes | `1.9.0` -> `2.0.0` | | MINOR | New features (backward compatible) | `2.0.0` -> `2.1.0` | | PATCH | Bug fixes (backward compatible) | `2.1.0` -> `2.1.1` | Pre-release versions: `2.0.0-alpha.1`, `2.0.0-beta.3`, `2.0.0-rc.1` Build metadata: `2.0.0+build.123` (ignored in version precedence) ## Conventional Commits Format: `<type>(<scope>): <description>` | Type | Version Bump | Example | |------|-------------|---------| | `fix` | PATCH | `fix(auth): handle expired tokens` | | `feat` | MINOR | `feat(api): add user search endpoint` | | `feat` + `BREAKING CHANGE:` | MAJOR | `feat(api)!: change response format` | | `docs`, `chore`, `ci`, `style`, `refactor`, `test`, `perf` | None | `docs: update API reference` | Breaking changes can be indicated two ways: ``` feat(api)!: remove legacy endpoint BREAKING CHANGE: The /v1/users endpoint has been removed. Use /v2/users instead. ``` ## Tool Comparison | Feature | semantic-release | changesets | release-please | goreleaser | |---------|-----------------|------------|----------------|------------| | Language | Any (Node-based) | Any (Node-based) | Any | Go projects | | Versioning | Automatic from commits | Manual (developer intent) | Automatic from commits | From git tags | | Changelog | Auto-generated | Manual + auto | Auto-generated | Auto-generated | | Monorepo | Via plugins | Native | Native | N/A | | CI integration | Deep | Moderate | GitHub-native | Deep | | NPM publish | Built-in | Built-in | Via workflow | N/A | | GitHub Release | Built-in | Via script | Built-in | Built-in | | Human review | No (fully auto) | Yes (PR-based) | Yes (PR-based) | No | | Best for | Full automation | Monorepos, team review | Google-style, simple setup | Go binaries | ## semantic-release Fully automated versioning and publishing based on commit messages. ### Configuration ```json // .releaserc.json { "branches": [ "main", { "name": "next", "prerelease": true }, { "name": "beta", "prerelease": true } ], "plugins": [ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", "@semantic-release/changelog", ["@semantic-release/npm", { "npmPublish": true }], ["@semantic-release/github", { "assets": ["dist/*.tar.gz"] }], ["@semantic-release/git", { "assets": ["CHANGELOG.md", "package.json"], "message": "chore(release): ${nextRelease.version} [skip ci]" }] ] } ``` ### GitHub Actions Workflow ```yaml # .github/workflows/release.yml name: Release on: push: branches: [main, next, beta] permissions: contents: write issues: write pull-requests: write packages: write jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 persist-credentials: false - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - run: npx semantic-release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} ``` ### Custom Commit Analyzer Rules ```json // .releaserc.json { "plugins": [ ["@semantic-release/commit-analyzer", { "preset": "conventionalcommits", "releaseRules": [ { "type": "perf", "release": "patch" }, { "type": "refactor", "release": "patch" }, { "type": "docs", "scope": "api", "release": "patch" } ] }] ] } ``` ## changesets Developer-driven versioning with PR-based workflow. Ideal for monorepos. ### Setup ```bash npx @changesets/cli init # Creates .changeset/ directory with config.json ``` ### Configuration ```json // .changeset/config.json { "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, "fixed": [], "linked": [["@myorg/core", "@myorg/utils"]], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": ["@myorg/docs", "@myorg/dev-tools"] } ``` ### Developer Workflow ```bash # 1. Create a changeset (interactive) npx changeset # 2. This creates a file like .changeset/brave-dogs-dance.md: # --- # "@myorg/core": minor # "@myorg/utils": patch # --- # # Add search functionality to core package # 3. Commit the changeset with your PR git add .changeset/brave-dogs-dance.md git commit -m "feat: add search functionality" ``` ### GitHub Actions Workflow ```yaml # .github/workflows/release.yml name: Release on: push: branches: [main] permissions: contents: write pull-requests: write packages: write jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - name: Create Release PR or Publish uses: changesets/action@v1 with: publish: npx changeset publish version: npx changeset version title: 'chore: version packages' commit: 'chore: version packages' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} ``` ## release-please Google's release automation. Creates release PRs automatically from conventional commits. ### GitHub Actions Workflow ```yaml # .github/workflows/release.yml name: Release on: push: branches: [main] permissions: contents: write pull-requests: write jobs: release: runs-on: ubuntu-latest outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} steps: - uses: googleapis/release-please-action@v4 id: release with: release-type: node # or python, go, simple, etc. # Steps that only run on release - uses: actions/checkout@v4 if: ${{ steps.release.outputs.release_created }} - uses: actions/setup-node@v4 if: ${{ steps.release.outputs.release_created }} with: node-version: 20 registry-url: https://registry.npmjs.org - run: npm ci && npm publish if: ${{ steps.release.outputs.release_created }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ``` ### Configuration ```json // release-please-config.json { "packages": { ".": { "release-type": "node", "changelog-path": "CHANGELOG.md", "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true } } } ``` ## goreleaser Release automation for Go projects: cross-compilation, archives, Docker images, and more. ### Configuration ```yaml # .goreleaser.yml version: 2 before: hooks: - go mod tidy - go generate ./... builds: - id: myapp main: ./cmd/myapp binary: myapp env: - CGO_ENABLED=0 goos: [linux, darwin, windows] goarch: [amd64, arm64] ldflags: - -s -w - -X main.version={{.Version}} - -X main.commit={{.Commit}} - -X main.date={{.Date}} archives: - id: default format: tar.gz format_overrides: - goos: windows format: zip name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" dockers: - image_templates: - "ghcr.io/owner/myapp:{{ .Version }}" - "ghcr.io/owner/myapp:latest" dockerfile: Dockerfile build_flag_templates: - "--build-arg=VERSION={{.Version}}" checksum: name_template: 'checksums.txt' changelog: sort: asc filters: exclude: - '^docs:' - '^chore:' - '^ci:' release: github: owner: myorg name: myapp draft: false prerelease: auto ``` ### GitHub Actions Workflow ```yaml # .github/workflows/release.yml name: Release on: push: tags: ['v*'] permissions: contents: write packages: write jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/setup-go@v5 with: go-version-file: go.mod - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: goreleaser/goreleaser-action@v6 with: version: '~> v2' args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` ### Local Testing ```bash # Dry run (no publish) goreleaser release --snapshot --clean # Check config goreleaser check # Build only (no release) goreleaser build --snapshot --clean ``` ## Changelog Generation ### Standalone Changelog Tools ```bash # conventional-changelog-cli npx conventional-changelog -p conventionalcommits -i CHANGELOG.md -s # git-cliff (Rust, fast) git cliff -o CHANGELOG.md git cliff --latest # Only latest release git cliff --unreleased # Only unreleased changes ``` ### git-cliff Configuration ```toml # cliff.toml [changelog] header = "# Changelog\n\n" body = """ {% for group, commits in commits | group_by(attribute="group") %} ### {{ group | upper_first }} {% for commit in commits %} - {{ commit.message | upper_first }} ({{ commit.id | truncate(length=7, end="") }})\ {% endfor %} {% endfor %} """ [git] conventional_commits = true filter_unconventional = true commit_parsers = [ { message = "^feat", group = "Features" }, { message = "^fix", group = "Bug Fixes" }, { message = "^perf", group = "Performance" }, { message = "^refactor", group = "Refactoring" }, ] ``` ## GitHub Releases ### Creating Releases with `gh` ```bash # Auto-generate notes from commits gh release create v1.2.0 --generate-notes # With title and custom notes gh release create v1.2.0 \ --title "v1.2.0" \ --notes "## What's New - Feature A - Bug fix B" # Upload assets gh release create v1.2.0 dist/*.tar.gz checksums.txt # Create draft release gh release create v1.2.0 --draft # Create pre-release gh release create v2.0.0-beta.1 --prerelease # Edit existing release gh release edit v1.2.0 --draft=false ``` ### GitHub Actions Release ```yaml - run: | gh release create "$TAG" \ --title "$TAG" \ --generate-notes \ dist/* env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ github.ref_name }} ``` ## NPM Publishing ### Complete NPM Release Workflow ```yaml name: Publish to NPM on: push: tags: ['v*'] permissions: contents: write id-token: write # For npm provenance jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 registry-url: https://registry.npmjs.org - run: npm ci - run: npm test - run: npm publish --provenance --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ``` ### Publishing to GitHub Packages ```yaml - uses: actions/setup-node@v4 with: node-version: 20 registry-url: https://npm.pkg.github.com scope: '@myorg' - run: npm publish env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` ## Docker Image Tagging ### Tagging Strategy | Tag | Source | Example | Purpose | |-----|--------|---------|---------| | `latest` | Main branch | `myapp:latest` | Most recent stable | | `x.y.z` | Git tag | `myapp:1.2.3` | Immutable release | | `x.y` | Git tag | `myapp:1.2` | Latest patch | | `x` | Git tag | `myapp:1` | Latest minor | | `sha-abc1234` | Commit SHA | `myapp:sha-abc1234` | Exact build | | `pr-42` | PR number | `myapp:pr-42` | PR preview | | `edge` | Main branch | `myapp:edge` | Bleeding edge | ### docker/metadata-action ```yaml - uses: docker/metadata-action@v5 id: meta with: images: | ghcr.io/${{ github.repository }} docker.io/myorg/myapp tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=sha,prefix= type=ref,event=branch type=ref,event=pr type=raw,value=latest,enable={{is_default_branch}} ``` ## Monorepo Release Strategies ### Independent Versioning (changesets) Each package has its own version. Best for library monorepos. ```json // .changeset/config.json { "fixed": [], "linked": [["@myorg/client-*"]], # These move together "access": "public" } ``` ### Fixed Versioning (release-please) All packages share one version. Best for application monorepos. ```json // release-please-config.json { "packages": { "packages/core": { "release-type": "node" }, "packages/cli": { "release-type": "node" }, "packages/web": { "release-type": "node" } }, "group-pull-requests-pattern": "chore: release main" } ``` ### Path-Filtered Releases ```yaml on: push: branches: [main] paths: - 'packages/api/**' jobs: release-api: runs-on: ubuntu-latest defaults: run: working-directory: packages/api steps: - uses: actions/checkout@v4 - run: npm ci - run: npm publish ``` ### Turborepo + changesets ```yaml jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 20, cache: npm } - run: npm ci - run: npx turbo run build --filter='...[origin/main]' - uses: changesets/action@v1 with: publish: npx changeset publish env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} ``` -
testing-pipelines.md 23.3 KB
# Testing Pipelines Reference ## Table of Contents - [Test Stages](#test-stages) - [Parallel Test Execution](#parallel-test-execution) - [Test Splitting Strategies](#test-splitting-strategies) - [Code Coverage](#code-coverage) - [Database Testing in CI](#database-testing-in-ci) - [Docker in CI](#docker-in-ci) - [E2E Testing in CI](#e2e-testing-in-ci) - [Flaky Test Detection and Retry](#flaky-test-detection-and-retry) - [Performance Testing in CI](#performance-testing-in-ci) - [Status Checks and Branch Protection](#status-checks-and-branch-protection) - [Pull Request Checks Workflow](#pull-request-checks-workflow) - [Deployment Pipelines](#deployment-pipelines) --- ## Test Stages A typical CI pipeline progresses through these stages, failing fast on cheap checks: ``` ┌─────────┐ ┌──────────┐ ┌─────────────┐ ┌──────────┐ ┌────────┐ │ Lint │──>│ Unit │──>│ Integration │──>│ E2E │──>│ Deploy │ │ ~1 min │ │ ~2 min │ │ ~5 min │ │ ~10 min │ │ │ └─────────┘ └──────────┘ └─────────────┘ └──────────┘ └────────┘ ``` ### Stage Characteristics | Stage | Speed | Dependencies | Flakiness | What It Catches | |-------|-------|-------------|-----------|----------------| | Lint / Format | Fastest | None | None | Style, syntax, type errors | | Unit tests | Fast | None (mocked) | Low | Logic bugs, regressions | | Integration | Medium | Services (DB, cache) | Medium | API contracts, data flow | | E2E | Slow | Full environment | High | User-facing regressions | | Performance | Slow | Full environment | Medium | Performance regressions | ### Staged Workflow ```yaml jobs: lint: 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 unit: needs: lint 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 test:unit -- --coverage integration: needs: lint runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_DB: test POSTGRES_USER: test POSTGRES_PASSWORD: test ports: ['5432:5432'] options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 20, cache: npm } - run: npm ci - run: npm run test:integration env: DATABASE_URL: postgresql://test:test@localhost:5432/test e2e: needs: [unit, integration] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 20, cache: npm } - run: npm ci - run: npx playwright install --with-deps chromium - run: npm run test:e2e - uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report path: playwright-report/ retention-days: 7 ``` ## Parallel Test Execution ### Matrix-Based Parallelism ```yaml jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 20, cache: npm } - run: npm ci - run: npm test -- --shard=${{ matrix.shard }}/${{ strategy.job-total }} ``` ### Playwright Sharding ```yaml strategy: matrix: shard: [1/4, 2/4, 3/4, 4/4] steps: - run: npx playwright test --shard=${{ matrix.shard }} - uses: actions/upload-artifact@v4 if: always() with: name: blob-report-${{ strategy.job-index }} path: blob-report/ # Merge reports in a separate job merge-reports: needs: test runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v4 with: pattern: blob-report-* merge-multiple: true path: all-blob-reports - run: npx playwright merge-reports --reporter html all-blob-reports ``` ### Jest Parallelism ```yaml # Jest auto-parallelizes across workers - run: npx jest --maxWorkers=50% # Use half available CPUs - run: npx jest --maxWorkers=4 # Or specify exactly # With sharding (Jest 28+) - run: npx jest --shard=${{ matrix.shard }}/${{ strategy.job-total }} ``` ## Test Splitting Strategies ### By File Count (Simple) ```bash # Split test files evenly across shards files=$(find src -name '*.test.ts' | sort) total=$(echo "$files" | wc -l) per_shard=$(( (total + SHARD_COUNT - 1) / SHARD_COUNT )) echo "$files" | sed -n "${start},${end}p" ``` ### By Timing (Optimal) ```yaml # Use test timing data from previous runs - uses: actions/cache@v4 with: path: .test-timings key: test-timings-${{ github.ref }} restore-keys: test-timings- - run: | npx jest --json --outputFile=results.json # Store timing data for next run jq '[.testResults[] | {file: .testFilePath, duration: .perfStats.runtime}]' \ results.json > .test-timings ``` ### By Test Type ```yaml strategy: matrix: include: - name: unit command: npm run test:unit timeout: 10 - name: integration command: npm run test:integration timeout: 20 - name: e2e command: npm run test:e2e timeout: 30 jobs: test: timeout-minutes: ${{ matrix.timeout }} steps: - run: ${{ matrix.command }} ``` ## Code Coverage ### Codecov ```yaml - run: npm test -- --coverage - uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage/lcov.info flags: unittests fail_ci_if_error: true ``` ### Coveralls ```yaml - run: npm test -- --coverage - uses: coverallsapp/github-action@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} path-to-lcov: coverage/lcov.info ``` ### Coverage Gates ```yaml # Fail if coverage drops - run: | COVERAGE=$(jq '.total.lines.pct' coverage/coverage-summary.json) echo "Coverage: ${COVERAGE}%" if (( $(echo "$COVERAGE < 80" | bc -l) )); then echo "::error::Coverage ${COVERAGE}% is below 80% threshold" exit 1 fi ``` ### Multi-Platform Coverage Merge ```yaml # Upload per-shard coverage - uses: actions/upload-artifact@v4 with: name: coverage-${{ matrix.shard }} path: coverage/ # Merge in separate job merge-coverage: needs: test runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v4 with: pattern: coverage-* merge-multiple: true path: all-coverage - run: npx nyc merge all-coverage merged-coverage.json - run: npx nyc report --reporter=lcov --temp-dir=. - uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} ``` ## Database Testing in CI ### Service Containers ```yaml services: postgres: image: postgres:16-alpine env: POSTGRES_DB: test_db POSTGRES_USER: test_user POSTGRES_PASSWORD: test_pass ports: ['5432:5432'] options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 redis: image: redis:7-alpine ports: ['6379:6379'] options: >- --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 mysql: image: mysql:8 env: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: test_db ports: ['3306:3306'] options: >- --health-cmd "mysqladmin ping -h localhost" --health-interval 10s --health-timeout 5s --health-retries 5 ``` ### Testcontainers ```yaml # Testcontainers manages its own containers - just needs Docker steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: { java-version: 21, distribution: temurin } # Testcontainers needs Docker socket access (default on ubuntu-latest) - run: ./gradlew test env: TESTCONTAINERS_RYUK_DISABLED: false ``` ### Database Migrations in CI ```yaml steps: - run: npm run db:migrate env: DATABASE_URL: postgresql://test_user:test_pass@localhost:5432/test_db - run: npm run db:seed # Optional test data - run: npm run test:integration env: DATABASE_URL: postgresql://test_user:test_pass@localhost:5432/test_db ``` ## Docker in CI ### Docker-in-Docker (DinD) ```yaml # Not recommended for GitHub Actions - use standard Docker # GitHub-hosted runners have Docker pre-installed steps: - uses: actions/checkout@v4 - run: docker build -t myapp . - run: docker run myapp npm test ``` ### Docker-outside-of-Docker (DooD) ```yaml # Mount the host Docker socket (for self-hosted runners) # GitHub-hosted runners use this by default steps: - run: docker compose up -d - run: docker compose run app npm test - run: docker compose down ``` ### Docker Compose in CI ```yaml steps: - uses: actions/checkout@v4 - run: docker compose -f docker-compose.test.yml up -d --wait - run: docker compose -f docker-compose.test.yml run app npm test - run: docker compose -f docker-compose.test.yml down -v # Alternative: use --exit-code-from - run: docker compose -f docker-compose.test.yml up --exit-code-from test ``` ## E2E Testing in CI ### Playwright ```yaml steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 20, cache: npm } - run: npm ci # Install browsers (cache for speed) - name: Cache Playwright browsers uses: actions/cache@v4 id: playwright-cache with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} - if: steps.playwright-cache.outputs.cache-hit != 'true' run: npx playwright install --with-deps chromium - if: steps.playwright-cache.outputs.cache-hit == 'true' run: npx playwright install-deps chromium # Run tests - run: npx playwright test env: CI: true # Upload artifacts on failure - uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report path: | playwright-report/ test-results/ retention-days: 7 ``` ### Cypress ```yaml steps: - uses: actions/checkout@v4 - uses: cypress-io/github-action@v6 with: build: npm run build start: npm start wait-on: 'http://localhost:3000' wait-on-timeout: 120 browser: chrome record: true # Cypress Cloud recording env: CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} - uses: actions/upload-artifact@v4 if: failure() with: name: cypress-screenshots path: cypress/screenshots/ ``` ### E2E with Containerized App ```yaml steps: - uses: actions/checkout@v4 # Start the app in Docker - run: docker compose up -d --wait # Run E2E tests against containerized app - run: npm ci - run: npx playwright test env: BASE_URL: http://localhost:3000 - run: docker compose down -v if: always() ``` ## Flaky Test Detection and Retry ### GitHub Actions Retry ```yaml # Retry the entire job - uses: nick-fields/retry@v3 with: timeout_minutes: 10 max_attempts: 3 command: npm run test:e2e retry_on: error ``` ### Built-in Test Runner Retries ```bash # Playwright npx playwright test --retries=2 # Jest npx jest --bail --forceExit # Fail fast, clean exit # Vitest npx vitest --retry=2 # pytest pip install pytest-rerunfailures pytest --reruns 3 --reruns-delay 1 ``` ### Flaky Test Quarantine Pattern ```yaml jobs: stable-tests: runs-on: ubuntu-latest steps: - run: npm test -- --testPathIgnorePatterns='flaky' flaky-tests: runs-on: ubuntu-latest continue-on-error: true # Don't block PR steps: - run: npm test -- --testPathPattern='flaky' --retries=3 ``` ### Detect New Flaky Tests ```yaml # Run tests multiple times on PR to detect flakiness - run: | for i in {1..5}; do echo "Run $i of 5" npm test -- --bail || exit 1 done ``` ## Performance Testing in CI ### Benchmark Comparison ```yaml - uses: benchmark-action/github-action-benchmark@v1 with: tool: 'customBiggerIsBetter' output-file-path: benchmark-results.json github-token: ${{ secrets.GITHUB_TOKEN }} auto-push: true alert-threshold: '150%' # Alert if 50% slower comment-on-alert: true fail-on-alert: true ``` ### Lighthouse CI ```yaml steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 20, cache: npm } - run: npm ci && npm run build - name: Start server run: npm start & env: { PORT: 3000 } - run: npx @lhci/cli autorun env: LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} ``` ### Lighthouse Configuration ```json // lighthouserc.json { "ci": { "collect": { "url": ["http://localhost:3000", "http://localhost:3000/about"], "numberOfRuns": 3 }, "assert": { "assertions": { "categories:performance": ["error", { "minScore": 0.9 }], "categories:accessibility": ["error", { "minScore": 0.95 }], "categories:best-practices": ["error", { "minScore": 0.9 }], "first-contentful-paint": ["warn", { "maxNumericValue": 2000 }] } }, "upload": { "target": "temporary-public-storage" } } } ``` ### Bundle Size Check ```yaml - uses: andresz1/size-limit-action@v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} # Reads config from .size-limit.json or package.json ``` ## Status Checks and Branch Protection ### Required Status Checks Configure in **Settings > Branches > Branch protection rules**: | Setting | Purpose | |---------|---------| | Require status checks to pass | Block merge until CI passes | | Require branches to be up to date | Ensure tests run against latest main | | Status checks to require | Select specific job names | ### Handling Skipped Checks with Path Filters Problem: Path-filtered workflows skip jobs, blocking required checks. Solution 1: Paths-filter action with always-running workflow: ```yaml name: CI on: [push, pull_request] jobs: changes: runs-on: ubuntu-latest outputs: src: ${{ steps.filter.outputs.src }} steps: - uses: dorny/paths-filter@v3 id: filter with: filters: | src: - 'src/**' - 'package.json' test: needs: changes if: needs.changes.outputs.src == 'true' runs-on: ubuntu-latest steps: - run: npm test # Always passes - use this as the required check ci-success: needs: [test] if: always() runs-on: ubuntu-latest steps: - run: | if [[ "${{ needs.test.result }}" == "failure" ]]; then exit 1 fi ``` Solution 2: Make the check non-required and use a merge queue. ### Merge Queue ```yaml on: merge_group: # Triggered by merge queue types: [checks_requested] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci && npm test ``` ## Pull Request Checks Workflow Complete PR workflow with all common checks: ```yaml name: PR Checks on: pull_request: branches: [main] concurrency: group: pr-${{ github.event.pull_request.number }} cancel-in-progress: true permissions: contents: read pull-requests: write checks: write jobs: # ── Fast Checks ──────────────────────────────────────── lint: name: Lint & Format runs-on: ubuntu-latest timeout-minutes: 5 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 format:check # ── Unit Tests ───────────────────────────────────────── unit: name: Unit Tests needs: lint runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 20, cache: npm } - run: npm ci - run: npm run test:unit -- --coverage - uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} flags: unittests # ── Integration Tests ───────────────────────────────── integration: name: Integration Tests needs: lint runs-on: ubuntu-latest timeout-minutes: 15 services: postgres: image: postgres:16-alpine env: POSTGRES_DB: test POSTGRES_USER: test POSTGRES_PASSWORD: test ports: ['5432:5432'] options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 20, cache: npm } - run: npm ci - run: npm run db:migrate env: DATABASE_URL: postgresql://test:test@localhost:5432/test - run: npm run test:integration env: DATABASE_URL: postgresql://test:test@localhost:5432/test # ── E2E Tests ───────────────────────────────────────── e2e: name: E2E Tests (${{ matrix.shard }}) needs: [unit, integration] runs-on: ubuntu-latest timeout-minutes: 20 strategy: fail-fast: false matrix: shard: [1/3, 2/3, 3/3] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 20, cache: npm } - run: npm ci - name: Cache Playwright uses: actions/cache@v4 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} - run: npx playwright install --with-deps chromium - run: npx playwright test --shard=${{ matrix.shard }} - uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report-${{ strategy.job-index }} path: playwright-report/ retention-days: 7 # ── Build Check ──────────────────────────────────────── build: name: Build needs: lint runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 20, cache: npm } - run: npm ci - run: npm run build - uses: actions/upload-artifact@v4 with: name: build path: dist/ retention-days: 1 # ── Gate Check (required status check) ───────────────── ci-success: name: CI Success needs: [lint, unit, integration, e2e, build] if: always() runs-on: ubuntu-latest steps: - run: | results=("${{ needs.lint.result }}" "${{ needs.unit.result }}" \ "${{ needs.integration.result }}" "${{ needs.e2e.result }}" \ "${{ needs.build.result }}") for result in "${results[@]}"; do if [[ "$result" == "failure" || "$result" == "cancelled" ]]; then echo "::error::Job failed with result: $result" exit 1 fi done echo "All checks passed" ``` ## Deployment Pipelines ### Staging to Production ```yaml name: Deploy on: push: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci && npm run build - uses: actions/upload-artifact@v4 with: name: build path: dist/ deploy-staging: needs: build runs-on: ubuntu-latest environment: name: staging url: https://staging.example.com steps: - uses: actions/download-artifact@v4 with: { name: build, path: dist/ } - run: ./deploy.sh staging env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} smoke-test: needs: deploy-staging runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npx playwright test tests/smoke/ env: BASE_URL: https://staging.example.com deploy-production: needs: smoke-test runs-on: ubuntu-latest environment: name: production # Manual approval required url: https://example.com steps: - uses: actions/download-artifact@v4 with: { name: build, path: dist/ } - run: ./deploy.sh production env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} ``` ### Blue/Green Deployment ```yaml deploy: runs-on: ubuntu-latest environment: production steps: - run: | # Deploy to inactive slot ACTIVE=$(curl -s https://example.com/slot) INACTIVE=$([[ "$ACTIVE" == "blue" ]] && echo "green" || echo "blue") # Deploy to inactive deploy --slot "$INACTIVE" # Health check on inactive curl -sf "https://${INACTIVE}.example.com/health" || exit 1 # Swap traffic swap-slots "$ACTIVE" "$INACTIVE" echo "Swapped from $ACTIVE to $INACTIVE" ``` ### Canary Deployment ```yaml deploy: runs-on: ubuntu-latest environment: production steps: - name: Deploy canary (10%) run: deploy --canary --weight=10 - name: Monitor canary (5 min) run: | for i in {1..5}; do ERROR_RATE=$(curl -s https://metrics.example.com/error-rate) if (( $(echo "$ERROR_RATE > 1.0" | bc -l) )); then echo "::error::Error rate ${ERROR_RATE}% exceeds threshold" deploy --rollback exit 1 fi sleep 60 done - name: Promote canary (100%) run: deploy --promote ``` ### Rollback Pattern ```yaml on: workflow_dispatch: inputs: version: description: 'Version to rollback to' required: true type: string jobs: rollback: runs-on: ubuntu-latest environment: production steps: - run: | echo "Rolling back to ${{ inputs.version }}" deploy --version "${{ inputs.version }}" - run: | curl -sf https://example.com/health || { echo "::error::Rollback health check failed" exit 1 } ``` ### Multi-Region Deployment ```yaml jobs: deploy: runs-on: ubuntu-latest environment: production strategy: max-parallel: 1 # Deploy one region at a time matrix: region: [us-east-1, eu-west-1, ap-southeast-1] steps: - run: deploy --region ${{ matrix.region }} - name: Region health check run: | curl -sf "https://${{ matrix.region }}.example.com/health" || { echo "::error::Health check failed in ${{ matrix.region }}" exit 1 } ```
-
-
scripts
-
.gitkeep 0 B · in bundle
-
-
SKILL.md 10.2 KB
--- name: ci-cd-ops description: "CI/CD pipeline patterns with GitHub Actions, release automation, and testing strategies. Use for: github actions, workflow, CI, CD, pipeline, deploy, release, semantic release, changesets, goreleaser, matrix, cache, secrets, environment, artifact, reusable workflow, composite action." license: MIT allowed-tools: "Read Write Bash" metadata: author: claude-mods related-skills: git-ops, docker-ops, testing-ops --- # CI/CD Operations Comprehensive patterns for continuous integration, delivery, and deployment using GitHub Actions, release automation tools, and testing pipelines. ## GitHub Actions Quick Reference ### Workflow File Anatomy ```yaml name: CI # Display name in Actions tab on: # Trigger events push: branches: [main] pull_request: branches: [main] permissions: # GITHUB_TOKEN scope (least privilege) contents: read pull-requests: write concurrency: # Prevent duplicate runs group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: # Workflow-level environment variables NODE_VERSION: "20" jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: npm - run: npm ci - run: npm test ``` ### Core Syntax Elements | Element | Purpose | Example | |---------|---------|---------| | `on` | Event triggers | `push`, `pull_request`, `schedule` | | `jobs.<id>.runs-on` | Runner selection | `ubuntu-latest`, `self-hosted` | | `jobs.<id>.needs` | Job dependencies | `needs: [build, lint]` | | `jobs.<id>.if` | Conditional execution | `if: github.event_name == 'push'` | | `jobs.<id>.strategy.matrix` | Parallel variants | `node-version: [18, 20, 22]` | | `jobs.<id>.environment` | Deployment target | `environment: production` | | `jobs.<id>.permissions` | Token scope | `contents: write` | | `steps[*].uses` | Use an action | `uses: actions/checkout@v4` | | `steps[*].run` | Run a command | `run: npm test` | | `steps[*].env` | Step environment | `env: { CI: true }` | ## Trigger Decision Tree | Scenario | Trigger | Config | |----------|---------|--------| | Run tests on every PR | `pull_request` | `branches: [main]` | | Deploy on merge to main | `push` | `branches: [main]` | | Release on version tag | `push` | `tags: ['v*']` | | Nightly builds | `schedule` | `cron: '0 2 * * *'` | | Manual deployment | `workflow_dispatch` | `inputs: { environment: ... }` | | Called by another workflow | `workflow_call` | `inputs:`, `secrets:` | | On PR label change | `pull_request` | `types: [labeled]` | | On issue comment | `issue_comment` | `types: [created]` | | On release published | `release` | `types: [published]` | | On package push | `registry_package` | `types: [published]` | ### Trigger Filter Patterns ```yaml on: push: branches: [main, 'release/**'] # Branch patterns paths: ['src/**', '!src/**/*.test.*'] # Path filters (ignore tests) tags: ['v*'] # Tag patterns pull_request: types: [opened, synchronize, reopened] # Default types paths-ignore: ['docs/**', '*.md'] # Ignore docs-only changes ``` ## Caching Strategies | Ecosystem | Action / Key | Path | Restore Key | |-----------|-------------|------|-------------| | Node (npm) | `actions/setup-node` with `cache: npm` | Auto | Auto | | Node (pnpm) | `actions/setup-node` with `cache: pnpm` | Auto | Auto | | Go modules | `actions/setup-go` with `cache: true` | Auto | Auto | | Cargo | `actions/cache@v4` | `~/.cargo/registry`, `target` | `cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}` | | pip / uv | `actions/setup-python` with `cache: pip` | Auto | Auto | | Docker layers | `docker/build-push-action` | Uses buildx cache | `type=gha` or `type=registry` | | Gradle | `actions/setup-java` with `cache: gradle` | Auto | Auto | | Composer | `actions/cache@v4` | `vendor` | `composer-${{ hashFiles('composer.lock') }}` | ### Manual Cache Example ```yaml - uses: actions/cache@v4 with: path: | ~/.cargo/bin ~/.cargo/registry ~/.cargo/git target key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} restore-keys: | cargo-${{ runner.os }}- ``` ## Matrix Strategy ```yaml strategy: fail-fast: false # Don't cancel siblings on failure max-parallel: 4 # Limit concurrent jobs matrix: os: [ubuntu-latest, windows-latest, macos-latest] node-version: [18, 20, 22] include: # Add specific combos - os: ubuntu-latest node-version: 22 coverage: true exclude: # Remove specific combos - os: windows-latest node-version: 18 ``` ### Dynamic Matrix ```yaml prepare: runs-on: ubuntu-latest outputs: matrix: ${{ steps.set.outputs.matrix }} steps: - id: set run: echo "matrix=$(jq -c . matrix.json)" >> "$GITHUB_OUTPUT" test: needs: prepare strategy: matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} ``` ## Secrets Management | Scope | Access | Use Case | |-------|--------|----------| | Repository secrets | All workflows in repo | API keys, tokens | | Environment secrets | Jobs targeting that environment | Production credentials | | Organization secrets | Selected repos in org | Shared service accounts | | OIDC tokens | Federated identity | Cloud deployment (no stored secrets) | ### Secrets Best Practices ```yaml # Reference secrets - NEVER echo or log them - run: deploy --token ${{ secrets.DEPLOY_TOKEN }} # Mask custom values - run: echo "::add-mask::$CUSTOM_SECRET" # Use environments for deployment secrets jobs: deploy: environment: production # Requires approval + has secrets steps: - run: deploy --key ${{ secrets.PROD_API_KEY }} ``` ### OIDC for Cloud (No Stored Secrets) ```yaml permissions: id-token: write contents: read steps: - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789:role/github-actions aws-region: us-east-1 ``` ## Common Workflow Patterns ### Test on Pull Request ```yaml name: Test on: pull_request: branches: [main] concurrency: group: test-${{ github.head_ref }} cancel-in-progress: true jobs: test: 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 test -- --coverage ``` ### Deploy on Merge to Main ```yaml name: Deploy on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest environment: production steps: - uses: actions/checkout@v4 - run: npm ci && npm run build - run: npx wrangler deploy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }} ``` ### Release on Tag ```yaml name: Release on: push: tags: ['v*'] permissions: contents: write jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: { fetch-depth: 0 } - run: | gh release create ${{ github.ref_name }} \ --generate-notes \ --title "${{ github.ref_name }}" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` ## Gotchas Table | Gotcha | Problem | Fix | |--------|---------|-----| | Shallow clone | `git describe` fails, history missing | `actions/checkout@v4` with `fetch-depth: 0` | | Default permissions | `GITHUB_TOKEN` is read-only by default | Set `permissions:` explicitly | | Action pinning | `@main` can break without warning | Pin to SHA: `@abc123` or `@v4` | | Fork PR secrets | Secrets unavailable on fork PRs | Use `pull_request_target` carefully | | Concurrent deploys | Race condition on production | Use `concurrency:` groups | | Stale caches | Cache grows unbounded | Include lockfile hash in key | | Node.js version | `setup-node` defaults vary | Always specify `node-version` | | Docker layer cache | Rebuilds everything without cache | Use `cache-from: type=gha` | | Matrix + environment | Each matrix job needs approval | Use a single deploy job after matrix | | Path filters + required checks | Skipped jobs block merge | Use `paths-filter` action or make checks non-required | | `GITHUB_TOKEN` in PRs | Cannot trigger other workflows | Use a PAT or GitHub App token | | Windows line endings | Scripts fail with `\r\n` | Use `.gitattributes` or `core.autocrlf` | ## Expression Syntax Quick Reference | Expression | Result | |------------|--------| | `${{ github.event_name }}` | `push`, `pull_request`, etc. | | `${{ github.ref_name }}` | Branch or tag name | | `${{ github.sha }}` | Full commit SHA | | `${{ github.actor }}` | User who triggered | | `${{ runner.os }}` | `Linux`, `Windows`, `macOS` | | `${{ contains(github.event.head_commit.message, '[skip ci]') }}` | Check commit message | | `${{ needs.build.outputs.version }}` | Output from prior job | | `${{ fromJson(steps.meta.outputs.json) }}` | Parse JSON output | | `${{ hashFiles('**/package-lock.json') }}` | Hash for cache keys | | `${{ format('refs/heads/{0}', matrix.branch) }}` | String formatting | | `${{ toJson(matrix) }}` | Debug: print matrix config | ## Step Outputs ```yaml steps: - id: version run: echo "value=$(cat VERSION)" >> "$GITHUB_OUTPUT" - run: echo "Version is ${{ steps.version.outputs.value }}" ``` ### Job Outputs (for Cross-Job Communication) ```yaml jobs: build: runs-on: ubuntu-latest outputs: artifact-id: ${{ steps.upload.outputs.artifact-id }} steps: - id: upload run: echo "artifact-id=abc123" >> "$GITHUB_OUTPUT" deploy: needs: build runs-on: ubuntu-latest steps: - run: echo "Deploying ${{ needs.build.outputs.artifact-id }}" ``` ## Reference Files | File | Contents | |------|----------| | `references/github-actions.md` | Complete workflow syntax, reusable workflows, composite actions, OIDC, runners, debugging | | `references/release-automation.md` | Semantic versioning, semantic-release, changesets, goreleaser, changelog, publishing | | `references/testing-pipelines.md` | Test stages, parallelism, coverage, service containers, e2e in CI, deployment pipelines |
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.