GitHub Actions
Complete Handbook
Every surface of GitHub Actions in one place — workflow anatomy, runners, the latest official actions (v5/v6), custom action types, caching, matrix builds, reusable workflows, secrets, OIDC, Dependabot automation, bots, usage limits, 2026 pricing changes, and production-ready CI/CD templates.
Workflow Anatomy
A GitHub Actions workflow is a YAML file in .github/workflows/. It defines when to run (triggers), where to run (runners), and what to run (jobs and steps).
name: CI Build # Display name in GitHub UI on: # Trigger definition push: branches: [main, develop] paths-ignore: ['**.md'] # skip if only docs changed pull_request: branches: [main] concurrency: # Cancel redundant runs group: ci-${{ github.ref }} cancel-in-progress: true permissions: # Least-privilege GITHUB_TOKEN contents: read pull-requests: write env: # Workflow-level env vars NODE_VERSION: '22' jobs: build: name: Build & Test runs-on: ubuntu-latest # Runner label timeout-minutes: 15 # Fail-safe timeout outputs: # Pass data between jobs version: ${{ steps.ver.outputs.version }} steps: - name: Checkout uses: actions/checkout@v6 # Pin major version - name: Setup Node uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' # Built-in dep caching - name: Install run: npm ci - name: Get version id: ver run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT - name: Lint run: npm run lint - name: Test run: npm test - name: Upload coverage if: always() uses: actions/upload-artifact@v4 with: name: coverage-${{ github.sha }} path: coverage/ retention-days: 14
Key YAML Fields
| Field | Scope | Purpose |
|---|---|---|
| name | workflow / job / step | Display name in GitHub UI. Use descriptive names. |
| on | workflow | Trigger(s) — event type, filters, branches, paths |
| concurrency | workflow / job | Group concurrent runs. cancel-in-progress: true kills stale runs. |
| permissions | workflow / job | Scope GITHUB_TOKEN. Default is write-all — always restrict. |
| env | workflow / job / step | Environment variables; more specific scope wins |
| runs-on | job | Runner label, runner group, or matrix expression |
| needs | job | Declares job dependencies — controls execution order |
| outputs | job | Expose step outputs for downstream jobs |
| timeout-minutes | job / step | Hard timeout. Default is 360 min. Always set lower. |
| if | job / step | Conditional expression — skips or runs based on context |
| uses | step | Reference an action or reusable workflow |
| with | step | Input parameters for an action |
| run | step | Inline shell script (bash by default) |
| continue-on-error | step | true — step failure does not fail the job |
| strategy.matrix | job | Define a build matrix (OS × language version etc.) |
Triggers & Events
| Trigger | When it fires | Common use |
|---|---|---|
| push | Push to branch or tag | CI on every commit, CD on tag push |
| pull_request | PR opened / sync / closed | CI gating — runs with PR head, limited secrets |
| pull_request_target | Same but runs with base branch context | Dependabot auto-merge, label actions (has write access) |
| workflow_dispatch | Manual trigger via UI or API | Hotfix deploys, manual release process |
| workflow_call | Called by another workflow | Reusable workflow entry point |
| schedule | Cron expression (UTC) | Nightly builds, security scans, dependency checks |
| release | Release published / created | Publish to npm, PyPI, container registry |
| create / delete | Branch or tag created / deleted | Notify, clean environments |
| issues | Issue opened / edited / closed | Auto-labelling, project board automation |
| issue_comment | Comment created on issue or PR | Bot commands e.g. /deploy staging |
| repository_dispatch | External webhook POST to GitHub API | Cross-repo triggers, external CI integration |
| merge_group | Merge Queue entry | Required status checks for Merge Queue |
| deployment_status | External deployment status update | Post-deploy test triggers |
| registry_package | Container package published | Downstream build triggers on image publish |
# Manual trigger with typed inputs on: workflow_dispatch: inputs: environment: description: 'Deploy target' required: true type: choice options: [staging, production] dry_run: type: boolean default: false # Path-filtered push (skip pure docs changes) on: push: branches: [main] paths: - 'src/**' - 'package*.json' paths-ignore: - 'docs/**' - '**.md' # Tag-based release trigger on: push: tags: ['v*.*.*'] # Nightly scheduled job (UTC) on: schedule: - cron: '0 2 * * *' # 02:00 UTC daily
Contexts & Expressions
| Context | Key properties |
|---|---|
| github | github.sha · github.ref · github.event_name · github.actor · github.repository · github.head_ref |
| env | All env vars defined at workflow / job / step scope |
| vars | Configuration variables (non-secret) set at repo / org level |
| secrets | secrets.GITHUB_TOKEN and any custom secrets |
| steps | steps.<id>.outputs.* · steps.<id>.conclusion |
| jobs | jobs.<job_id>.result (reusable workflows) |
| needs | needs.<job_id>.outputs.* · needs.<job_id>.result |
| runner | runner.os · runner.arch · runner.temp |
| matrix | matrix.<key> — current cell values in a matrix strategy |
# Conditional step — only on main if: github.ref == 'refs/heads/main' # Always run (even if job failed) if: always() # Run on failure only if: failure() # Check if PR is from Dependabot if: github.actor == 'dependabot[bot]' # Ternary-style using fromJSON env: TARGET: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }} # Write to GITHUB_OUTPUT (step outputs) run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT # Write to GITHUB_STEP_SUMMARY (shows in UI) run: echo "## Build passed ✅" >> $GITHUB_STEP_SUMMARY # Mask a value in logs run: echo "::add-mask::$SECRET_VALUE" # Add a warning annotation run: echo "::warning file=src/main.ts,line=42::Deprecated API"
GitHub-Hosted Runners
GitHub-hosted runners are ephemeral VMs provisioned fresh for every job. Free for public repos. Private repos consume included minutes then billed. Prices reduced up to 39% from January 2026.
| Runner label | OS | vCPU | RAM | Storage | Billing |
|---|---|---|---|---|---|
ubuntu-latestubuntu-24.04 |
Ubuntu 24.04 | 4 | 16 GB | 14 GB SSD | 1× Linux rate |
| ubuntu-22.04 | Ubuntu 22.04 | 4 | 16 GB | 14 GB SSD | 1× Linux rate |
windows-latestwindows-2025 |
Windows Server 2025 | 4 | 16 GB | 14 GB SSD | 2× Linux rate |
macos-latestmacos-15 |
macOS 15 (Apple Silicon) | 3 | 7 GB | 14 GB | 10× Linux rate |
| macos-13 | macOS 13 (Intel) | 4 | 14 GB | 14 GB | 10× Linux rate |
ubuntu-latest tracks the current LTS. When it moves (e.g. 22.04→24.04), your workflow may break. Pin to ubuntu-24.04 in production workflows and update deliberately.Larger Runners
Larger runners offer more CPU/RAM and advanced features. Available on GitHub Team and Enterprise Cloud plans.
| Runner | vCPU | RAM | Key feature |
|---|---|---|---|
| ubuntu-latest-4-cores | 4 | 16 GB | Larger baseline |
| ubuntu-latest-8-cores | 8 | 32 GB | Heavy builds |
| ubuntu-latest-16-cores | 16 | 64 GB | ML / large Docker builds |
| ubuntu-latest-64-cores | 64 | 256 GB | Enterprise-grade parallelism |
| ARM runners | 4–64 | 16–256 GB | Native ARM builds (no QEMU) |
| GPU runners | 4+ | 16+ GB | NVIDIA T4/A10G — ML training |
Larger runners also support: static IP addresses, VNet injection (Azure), custom machine images, and dedicated allocations.
Self-Hosted Runners
- You need custom hardware (GPU, ARM, FPGA, high memory)
- Jobs access internal services or private network resources
- Build times are very long and cost of hosted runners is high
- You need to pre-install proprietary software or licenses
- Compliance requires data to stay on-premises
- Runners receive untrusted code from fork pull requests
- You don't want to manage runner infrastructure and patching
- Ephemeral, clean environments per job are important
- You want GitHub to handle scaling automatically
# Single label runs-on: self-hosted # Multiple labels (all must match) runs-on: [self-hosted, linux, gpu] # Runner group (org-level) runs-on: group: production-runners labels: [linux, x64] # Ephemeral self-hosted (recommended for security) # Register with --ephemeral flag so runner deregisters after one job # Use Actions Runner Controller (ARC) for Kubernetes-based autoscaling
Official Actions Reference (v5/v6)
actions/checkout@v6 is stable. Most actions are now on v4–v6. Use the highest stable major version — minor/patch updates are automatic when pinning to a major tag.
Most-Used Official Actions
| Action | Latest | Key changes |
|---|---|---|
| actions/checkout | v6 | v6 stable — sparse checkout, multi-repo, filter blobs. v5 added submodule performance improvements. |
| actions/setup-node | v4 | Built-in npm/yarn/pnpm cache via cache: input. Supports .nvmrc, .node-version. |
| actions/setup-python | v5 | pip/pipenv/poetry cache built-in. python-version-file: .python-version support. |
| actions/setup-java | v4 | Maven/Gradle cache. Multiple JDK distributions (temurin, corretto, zulu, liberica). |
| actions/setup-dotnet | v4 | Multi-version support. NuGet cache. Works with global.json. |
| actions/setup-go | v5 | Module cache built-in. Reads go.mod for version. |
| actions/cache | v4 | Cache >10 GB per repo (Nov 2025). save-always: true input. Parallel restore. |
| actions/upload-artifact | v4 | v3 deprecated Jan 2025. Non-zipped upload option (Feb 2026). 98% faster than v3. |
| actions/download-artifact | v4 | Parallel downloads. pattern: glob matching. merge-multiple: true for matrix. |
| actions/github-script | v7 | Run JS with full Octokit API client. TypeScript support. |
| actions/create-release | deprecated | Use softprops/action-gh-release@v2 instead — richer features. |
| docker/build-push-action | v6 | Buildx / BuildKit. Multi-platform (amd64 + arm64). Cache from/to registry. |
| docker/login-action | v3 | Log in to GHCR, Docker Hub, ECR, ACR, GCR, custom registry. |
| docker/metadata-action | v5 | Generate image tags/labels from git refs, SemVer, SHA. |
actions/checkout@v6 — Key Inputs
- uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.sha }} # pin PR SHA fetch-depth: 0 # full history (needed for git log, SemVer) sparse-checkout: | # only checkout specific dirs src/ tests/ token: ${{ secrets.MY_PAT }} # for private repo or push-back submodules: recursive # init all submodules lfs: true # checkout Git LFS files
actions/upload-artifact@v4 — Non-Zipped (Feb 2026)
# Upload — v4 default is zipped - uses: actions/upload-artifact@v4 with: name: build-${{ github.sha }} path: | dist/ !dist/**/*.map # exclude source maps retention-days: 30 if-no-files-found: error # fail if no files match compression-level: 0 # 0 = no zip (Feb 2026 feature) # Download — pattern matches multiple artifacts from matrix - uses: actions/download-artifact@v4 with: pattern: build-* # glob across all named artifacts path: all-builds/ merge-multiple: true # merge into single dir
Custom Actions
Custom actions let you package reusable logic. Three types exist — choose based on dependencies, performance needs, and portability requirements.
Shell steps + uses: references. No build step. Runs directly on runner. Best for multi-step workflows you want to reuse.
Node.js. Fastest startup. Runs directly on runner (no Docker). Use @actions/core, @actions/github. Best for most custom logic.
Any language inside a container. Consistent environment. Slower startup (image pull). Linux only. Best for heavy dependencies or non-JS runtimes.
Composite Action
name: 'Setup Environment' description: 'Install Node, cache deps, set env vars' inputs: node-version: description: 'Node version to use' required: false default: '22' outputs: cache-hit: description: 'Whether the cache was hit' value: ${{ steps.cache.outputs.cache-hit }} runs: using: composite steps: - uses: actions/setup-node@v4 with: node-version: ${{ inputs.node-version }} cache: npm id: cache - name: Install dependencies run: npm ci shell: bash # required in composite actions # Usage in a workflow: # - uses: ./.github/actions/setup-env # with: # node-version: '22'
JavaScript Action
const core = require('@actions/core'); const github = require('@actions/github'); async function run() { try { // Read inputs defined in action.yml const token = core.getInput('github-token', { required: true }); const message = core.getInput('message'); // GitHub API client const octokit = github.getOctokit(token); const { context } = github; // Comment on the PR await octokit.rest.issues.createComment({ ...context.repo, issue_number: context.payload.pull_request.number, body: message, }); // Set output core.setOutput('commented', 'true'); core.info('Comment posted successfully'); } catch (err) { core.setFailed(err.message); // fails the step } } run();
name: 'PR Commenter' description: 'Post a comment on a pull request' inputs: github-token: required: true message: required: true outputs: commented: description: 'Whether comment was posted' runs: using: node20 # node16 / node20 — use node20 main: dist/index.js # bundled output (ncc, esbuild) post: dist/cleanup.js # optional: run after job completes
Secrets, Variables & Environments
| Type | Access in workflow | Scope | Use for |
|---|---|---|---|
| Encrypted secret | secrets.MY_SECRET | Repo / Org / Env | API keys, passwords, tokens — never logged |
| Configuration variable | vars.MY_VAR | Repo / Org / Env | Non-sensitive config: URLs, feature flags, version numbers |
| GITHUB_TOKEN | secrets.GITHUB_TOKEN | Per-job (auto) | GitHub API calls — expires after job. Scope with permissions: |
| Environment secret | secrets.PROD_KEY | Named environment | Secrets that differ per deploy target (staging vs prod) |
| Dependabot secret | secrets.MY_SECRET | Dependabot-specific | Secrets for Dependabot workflows — separate from Actions secrets |
Permissions & OIDC
permissions: id-token: write # Required for OIDC contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 # AWS — no long-lived access keys needed - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789:role/GitHubDeploy aws-region: us-east-1 # Azure — no service principal password needed - uses: azure/login@v2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} # GCP — Workload Identity Federation - uses: google-github-actions/auth@v2 with: workload_identity_provider: 'projects/123/locations/global/workloadIdentityPools/...' service_account: 'deploy@myproject.iam.gserviceaccount.com'
Caching & Artifacts
# npm — explicit cache (or use setup-node cache: 'npm') - uses: actions/cache@v4 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} restore-keys: | # Fallback prefix matches npm-${{ runner.os }}- npm- save-always: true # Save cache even if later steps fail # pip / Python - uses: actions/cache@v4 with: path: ~/.cache/pip key: pip-${{ runner.os }}-${{ hashFiles('**/requirements*.txt') }} # Gradle / JVM - uses: actions/cache@v4 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*') }} # Docker layer cache to registry - uses: docker/build-push-action@v6 with: cache-from: type=registry,ref=ghcr.io/org/app:cache cache-to: type=registry,ref=ghcr.io/org/app:cache,mode=max
Matrix Strategy
jobs: test: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] node: ['20', '22'] include: # Add extra matrix cells - os: ubuntu-latest node: '22' coverage: true exclude: # Remove specific combinations - os: windows-latest node: '20' fail-fast: false # Don't cancel all on first failure max-parallel: 4 # Limit concurrent jobs runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - run: npm ci && npm test - name: Coverage if: matrix.coverage # only for specific matrix cell run: npm run coverage - uses: actions/upload-artifact@v4 with: name: results-${{ matrix.os }}-node${{ matrix.node }} path: test-results/
Reusable Workflows
Reusable workflows let you call a workflow from another workflow — sharing CI logic across repos without copy-pasting YAML. Called via workflow_call trigger.
on: workflow_call: inputs: environment: type: string required: true secrets: DEPLOY_KEY: required: true outputs: version: description: 'Built version' value: ${{ jobs.build.outputs.version }} jobs: build: runs-on: ubuntu-latest outputs: version: ${{ steps.v.outputs.version }} steps: - uses: actions/checkout@v6 - id: v run: echo "version=1.0.0" >> $GITHUB_OUTPUT
jobs: build: uses: my-org/.github/.github/workflows/shared-build.yml@main with: environment: production secrets: DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} # Or pass ALL caller secrets: # secrets: inherit notify: needs: build runs-on: ubuntu-latest steps: - run: echo "Built ${{ needs.build.outputs.version }}"
Concurrency & Job Ordering
# Cancel in-progress runs for the same branch concurrency: group: deploy-${{ github.ref }} cancel-in-progress: true jobs: lint: runs-on: ubuntu-latest steps: [...] test: needs: lint # runs after lint runs-on: ubuntu-latest steps: [...] build: needs: test runs-on: ubuntu-latest outputs: image-tag: ${{ steps.tag.outputs.tag }} steps: [...] deploy-staging: needs: build environment: staging # triggers protection rules / approvals runs-on: ubuntu-latest steps: [...] deploy-prod: needs: [build, deploy-staging] environment: production if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: [...]
Usage Limits
timeout-minutes2026 Billing & Pricing Changes
| Plan | Included minutes/mo | Included storage | Extra cost |
|---|---|---|---|
| Free | 2,000 (Linux) | 500 MB | $0.008/min Linux |
| Pro | 3,000 (Linux) | 1 GB | $0.008/min Linux |
| Team | 3,000 (Linux) | 2 GB | $0.008/min Linux |
| Enterprise | 50,000 (Linux) | 50 GB | $0.008/min Linux |
| Runner type | Rate (from Jan 2026) | vs Linux multiplier |
|---|---|---|
| Linux (standard) | $0.008/min | 1× |
| Windows (standard) | $0.016/min | 2× |
| macOS (standard) | $0.08/min | 10× |
| Self-hosted (from Mar 2026) | $0.002/min platform charge | — (plus your own compute costs) |
| Larger Linux (4-core) | $0.016/min | 2× |
| Larger Linux (16-core) | $0.064/min | 8× |
Dependabot — Full Reference
Dependabot is GitHub's native dependency scanner and updater. It creates PRs to update outdated or vulnerable dependencies. Works with 20+ package ecosystems.
dependabot.yml Configuration
version: 2 updates: # npm — weekly batch, grouped updates - package-ecosystem: npm directory: / schedule: interval: weekly day: monday time: '09:00' timezone: Europe/London groups: # Group related deps into one PR dev-dependencies: patterns: ['*'] dependency-type: development production-dependencies: patterns: ['*'] dependency-type: production open-pull-requests-limit: 5 # cap PR count reviewers: [myorg/platform-team] labels: [dependencies, automated] cooldown: # Wait before opening PR (supply chain safety) default-days: 3 # GitHub Actions — pin by SHA, update weekly - package-ecosystem: github-actions directory: / schedule: interval: weekly # Docker - package-ecosystem: docker directory: / schedule: interval: monthly ignore: - dependency-name: node update-types: [version-update:semver-major]
Dependabot Auto-Merge Workflow
name: Dependabot Auto-Merge on: pull_request_target: types: [opened, synchronize, reopened] permissions: contents: write pull-requests: write jobs: dependabot: runs-on: ubuntu-latest if: github.actor == 'dependabot[bot]' steps: # Fetch PR metadata (update-type, dependency-name, etc.) - name: Fetch Dependabot metadata id: meta uses: dependabot/fetch-metadata@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} # Auto-approve + merge patch updates - name: Approve patch updates if: steps.meta.outputs.update-type == 'version-update:semver-patch' run: | gh pr review --approve "$PR_URL" gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Auto-approve + merge minor updates for dev deps - name: Approve minor dev dep updates if: | steps.meta.outputs.update-type == 'version-update:semver-minor' && steps.meta.outputs.dependency-type == 'direct:development' run: | gh pr review --approve "$PR_URL" gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Label major updates for manual review - name: Label major updates if: steps.meta.outputs.update-type == 'version-update:semver-major' run: gh pr edit "$PR_URL" --add-label "needs-review" env: PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
cooldown.default-days: 3, pin Actions to commit SHAs (not tags), do NOT auto-merge major updates, and review Actions update PRs manually.Dependabot Supported Ecosystems
| Ecosystem | Package manager | Directory |
|---|---|---|
| npm | npm / yarn / pnpm | package.json location |
| pip | pip / pip-compile / pipenv / Poetry | requirements.txt / pyproject.toml |
| nuget | NuGet (.NET) | *.csproj / packages.lock.json |
| maven | Maven / Gradle | pom.xml / build.gradle |
| cargo | Rust/Cargo | Cargo.toml |
| gomod | Go modules | go.mod |
| bundler | Ruby/Bundler | Gemfile |
| github-actions | GitHub Actions | .github/workflows/ |
| docker | Dockerfile | Dockerfile location |
| terraform | Terraform registry | *.tf files |
| helm | Helm charts | Chart.yaml location |
| pub | Dart/Flutter | pubspec.yaml |
GitHub Bots & Automation
.github/dependabot.ymlrenovate.json.github/labeler.ymlactions/labeler@v5GITHUB_TOKEN to create comments, labels, or commitsgithub-actions[bot]github/codeql-actiongoogle-github-actions/release-please-action@v4actions/stale@v9codelytv/pr-size-labeler@v1When to Use Dependabot vs Renovate
- Simple setup — zero additional configuration required
- You need security alerts and auto-fix PRs natively
- Team prefers GitHub-native, no external app needed
- Repo is small, few ecosystems, infrequent updates
- You need complex grouping, custom versioning rules
- Monorepo with many packages across ecosystems
- You want a Dependency Dashboard PR overview
- Auto-merge based on test results or semantic confidence
- Pinning Docker/Actions to commit SHAs automatically
CI: Node.js
name: Node.js CI on: push: branches: [main] pull_request: branches: [main] concurrency: group: node-ci-${{ github.ref }} cancel-in-progress: true jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v4 with: { node-version: '22', cache: npm } - run: npm ci --prefer-offline - run: npm run lint - run: npm run type-check test: needs: lint strategy: matrix: node: ['20', '22'] fail-fast: false runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v4 with: { node-version: '${{ matrix.node }}', cache: npm } - run: npm ci --prefer-offline - run: npm test -- --coverage - uses: actions/upload-artifact@v4 if: always() with: name: coverage-node-${{ matrix.node }} path: coverage/ retention-days: 7
CI: .NET / C#
name: .NET CI on: push: branches: [main] pull_request: branches: [main] concurrency: group: dotnet-ci-${{ github.ref }} cancel-in-progress: true jobs: build-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: '8.x' - name: Restore run: dotnet restore - name: Build run: dotnet build --no-restore --configuration Release - name: Test run: | dotnet test --no-build \ --configuration Release \ --logger "trx;LogFileName=results.trx" \ --collect:"XPlat Code Coverage" - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: test-results path: '**/*.trx' - name: Publish test report uses: dorny/test-reporter@v1 if: always() with: name: .NET Tests path: '**/*.trx' reporter: dotnet-trx
CI: Docker Build & Push
name: Docker Build on: push: branches: [main] tags: ['v*.*.*'] pull_request: branches: [main] permissions: contents: read packages: write # Required for GHCR push env: REGISTRY: ghcr.io IMAGE: ghcr.io/${{ github.repository }} jobs: build-push: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GHCR if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) id: meta uses: docker/metadata-action@v5 with: images: ${{ env.IMAGE }} tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha,prefix=sha- type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - name: Build and push uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max provenance: false # Avoid manifest list issues
CD: GitHub Pages
name: Deploy to Pages on: push: branches: [main] workflow_dispatch: # Manual deploy button permissions: contents: read pages: write id-token: write # Required for Pages deployment concurrency: group: pages cancel-in-progress: false # Never cancel in-flight deploy jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v4 with: { node-version: '22', cache: npm } - run: npm ci && npm run build - uses: actions/configure-pages@v5 - uses: actions/upload-pages-artifact@v3 with: path: dist/ deploy: needs: build runs-on: ubuntu-latest environment: name: github-pages url: ${{ steps.deploy.outputs.page_url }} steps: - id: deploy uses: actions/deploy-pages@v4
CD: Release & Deploy
name: Release & Deploy on: push: tags: ['v*.*.*'] permissions: contents: write packages: write id-token: write jobs: # Build matrix: produce binaries for 3 platforms build: strategy: matrix: include: - { os: ubuntu-latest, target: linux-x64 } - { os: windows-latest, target: win-x64 } - { os: macos-latest, target: osx-arm64 } runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 - uses: actions/setup-dotnet@v4 with: { dotnet-version: '8.x' } - run: | dotnet publish src/App/App.csproj \ --runtime ${{ matrix.target }} \ --configuration Release \ --self-contained true \ --output dist/ - uses: actions/upload-artifact@v4 with: name: app-${{ matrix.target }} path: dist/ # Create GitHub Release with all binaries release: needs: build runs-on: ubuntu-latest outputs: tag: ${{ steps.t.outputs.tag }} steps: - uses: actions/download-artifact@v4 with: pattern: app-* path: release/ merge-multiple: true - id: t run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - uses: softprops/action-gh-release@v2 with: name: Release ${{ steps.t.outputs.tag }} generate_release_notes: true files: release/** # Deploy to staging (with environment protection) deploy-staging: needs: release runs-on: ubuntu-latest environment: name: staging url: https://staging.example.com steps: - uses: actions/checkout@v6 # OIDC auth + deploy script here - run: ./scripts/deploy.sh staging ${{ needs.release.outputs.tag }} # Deploy to production (requires manual approval in GitHub) deploy-prod: needs: deploy-staging runs-on: ubuntu-latest environment: name: production # Required reviewers set in GitHub UI url: https://example.com steps: - uses: actions/checkout@v6 - run: ./scripts/deploy.sh production ${{ needs.release.outputs.tag }}
Anti-Patterns & Pitfalls
| Anti-pattern | Problem | Fix |
|---|---|---|
| No timeout-minutes | Runaway jobs consume 6 hours of minutes; blocks concurrent jobs | Set timeout-minutes: 15 (or appropriate) on every job |
| Pinning to floating tags | uses: actions/something@v3 may be updated maliciously or break without notice |
Pin third-party to SHA: uses: org/action@a1b2c3d. Use Dependabot to update. |
| Secrets in run commands | Secrets printed in logs, leaked in error output | Use echo "::add-mask::$SECRET"; never interpolate secrets in run strings |
| Hardcoded permissions write-all | Default GITHUB_TOKEN has write access to all scopes — excessive privilege | Set permissions: {} at top level and add only what's needed per job |
| Self-hosted on public repos | Fork PRs can modify workflows to exfiltrate runner secrets or environment | Use GitHub-hosted runners for all public repos, or restrict with pull_request_target carefully |
| No concurrency groups | Multiple PRs or fast pushes create redundant parallel runs wasting minutes | Add concurrency: { group: ..., cancel-in-progress: true } |
| No cache | Every job reinstalls all dependencies from scratch — slow, expensive | Use setup-node cache: npm or actions/cache@v4 with lockfile hash key |
| Auto-merge all Dependabot PRs | Malicious package updates merged without review (supply chain attack) | Only auto-merge patch; use cooldown; manually review Actions SHA updates |
| Using workflow_run for auth | Complex trust chain — easier to misconfigure than pull_request_target |
Prefer explicit actor checks and environment protection rules |
| Large monolithic workflows | One failure resets entire 20-minute workflow; no parallelism | Split into lint/test/build/deploy jobs with needs:; fail fast at lint |
Workflow Checklist
Click each item to track your review.
- timeout-minutes set on every job — never rely on the 6-hour default.
- permissions scoped at workflow and job level — default is write-all, always restrict.
- Actions pinned by SHA for third-party actions, especially in security-sensitive workflows.
- Secrets not echoed in
run:steps. Useadd-maskwhen dynamic secret values must be computed. - concurrency group configured to cancel stale runs on PR updates and branch pushes.
- Dependency caching enabled for npm/pip/gradle/cargo — reduces job time significantly.
- Dependabot configured for all package ecosystems including
github-actionsecosystem. - Dependabot auto-merge scoped — patch only. Major updates require manual review.
- OIDC used for cloud auth — no long-lived secrets stored for AWS/Azure/GCP authentication.
- Environment protection rules set for production — required reviewers, deployment branch restrictions.
- Reusable workflows used for shared CI logic across repos — not copy-paste YAML.
- Self-hosted runner security — ephemeral (
--ephemeral), not used on public repos, isolated network. - artifact retention-days set appropriately — not left at 90 days default for every artifact type.
- Lint / fast gates run first — fail early at lint/type-check before expensive test and build jobs.