GitHub Actions: CI/CD Workflow Guide

Set up CI/CD with GitHub Actions: workflow anatomy, triggers, secrets, caching, and a complete .NET build-and-test pipeline.

Published Last reviewed

1

The Moving Parts

Everything in GitHub Actions hangs off five nouns. Once these click, any workflow file on GitHub reads the same way:

ConceptWhat it is
Event Something that happens in the repository: a push, a pull request, a schedule firing, a manual button press. Events start workflows.
Workflow A YAML file in .github/workflows/ describing what to run and when. A repository can have as many as it needs; each runs independently.
Job A group of steps that runs on one runner. Jobs in the same workflow run in parallel by default; use needs: to chain them.
Step One command (run:) or one reusable action (uses:). Steps run in order, top to bottom, and share the job's filesystem.
Runner The machine a job runs on. ubuntu-latest is the workhorse; Windows and macOS images exist for platform-specific builds.

The one that trips people up is the job/step split: two steps can pass files to each other because they share a machine; two jobs cannot, because each gets a fresh runner. Moving files between jobs takes artifacts (Section 6).

2

Anatomy of a Workflow

The smallest useful workflow shows the whole shape. Save it as .github/workflows/ci.yml. The path is what makes GitHub pick it up; the filename is yours to choose.

# .github/workflows/ci.yml
name: CI                    # shown in the Actions tab and on status checks

on:                         # the events that start this workflow
  push:
    branches: [ main ]
  pull_request:             # no filter = every PR targeting any branch

jobs:
  build:                    # job id: any name, referenced by needs:
    runs-on: ubuntu-latest
    steps:
      # uses: runs a published action. The version after @ is a git
      # ref. Pin at least to a major version; never leave it off.
      - uses: actions/checkout@v6

      # run: executes shell commands on the runner
      - run: echo "The repo is checked out at $GITHUB_WORKSPACE"

Two things are easy to miss at first. The runner starts empty: without actions/checkout your code simply isn't there, which is why it opens nearly every job on GitHub. And on: accepts a single event, a list, or the filtered form above; the filters decide how often your minutes get spent, so they're worth getting right early.

3

A Real Pipeline: Build and Test a .NET App

This is the workflow I reach for on a .NET project: build every push to main and every pull request against it, fail fast if the code doesn't compile or a test breaks.

name: Build & Test

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

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Check out the repository
        uses: actions/checkout@v6

      - name: Set up .NET
        uses: actions/setup-dotnet@v5
        with:                          # with: passes inputs to an action
          dotnet-version: 8.0.x       # any 8.0 patch, so servicing releases are picked up

      - name: Restore dependencies
        run: dotnet restore

      # --no-restore / --no-build skip repeating work the previous
      # steps already did; the split makes failures easy to read in
      # the log: restore, compile, and test problems land in
      # different steps instead of one wall of output.
      - name: Build
        run: dotnet build --no-restore --configuration Release

      - name: Test
        run: dotnet test --no-build --configuration Release

Push this file and the pipeline is live; there's nothing to install or enable. Every matching push and PR gets a โœ“ or โœ— next to its commit, and clicking it shows the per-step log.

To make the check enforce anything, turn on branch protection (Settings → Branches → require status checks to pass) and select this workflow. Until then a red โœ— is advice, not a gate, and PRs can still merge right past it.

Gate the deploy on the tests

When the same workflow also deploys, chain the jobs with needs: so a failing test run means nothing ships. Jobs run in parallel by default; needs: is what turns them into a pipeline. This site deploys to Cloudflare Pages exactly this way.

jobs:
  unit:
    # ...runs the unit tests...
  e2e:
    # ...builds the site and drives it in a real browser...
  deploy:
    needs: [ unit, e2e ]   # skipped entirely if either fails
    runs-on: ubuntu-latest
    steps:
      # ...build and push to the host...

The same trick gives you scheduled monitoring for free: a tiny workflow on a schedule: trigger that curls your live endpoints and opens an issue when one stops answering. Tests prove the code worked at deploy time; a monitor notices when the world changes afterward.

4

Triggers Worth Knowing

push and pull_request cover CI. Three more triggers cover almost everything else:

schedule: run on a cron
on:
  schedule:
    # min hour day month weekday, always in UTC
    - cron: "0 6 * * 1"   # Mondays 06:00 UTC (nightly builds, link checks, stale sweeps)
workflow_dispatch: a manual Run button
on:
  workflow_dispatch:
    inputs:
      environment:
        description: "Where to deploy"
        type: choice
        options: [ staging, production ]
        default: staging

# read it later as ${{ github.event.inputs.environment }}
paths: only when relevant files change
on:
  push:
    branches: [ main ]
    paths:
      - "src/**"
      - "*.csproj"
      # docs-only commits no longer burn a build

One quiet footgun: workflow_dispatch only appears in the Actions tab once the workflow file exists on the default branch. A manual trigger sitting on a feature branch shows no button, which looks exactly like it's broken.


Try it: pick the events you care about and watch the on: block write itself. For the schedule field, the Cron Expression Explainer turns any cron string into plain English with its next run times.


            
5

Secrets and Variables

API keys and deploy credentials go in Settings → Secrets and variables → Actions, never in the YAML. Secrets are encrypted, masked as *** if they ever hit a log, and unreadable after saving; variables are the same mechanism for non-sensitive config.

steps:
  - name: Deploy
    run: ./deploy.sh
    env:
      # ${{ }} is the expressions syntax, evaluated by GitHub
      # before the step runs, not by the shell
      API_KEY: ${{ secrets.DEPLOY_API_KEY }}
      TARGET_URL: ${{ vars.STAGING_URL }}

Every run also gets an automatic GITHUB_TOKEN secret: short-lived credentials for the repository itself. It's what lets a workflow push a tag, comment on a PR, or publish a release without any setup. Scope what it's allowed to do per workflow:

permissions:            # top-level: applies to every job in this workflow
  contents: read        # checkout only, the safe default for plain CI

Two rules save the most grief. Secrets are not passed to workflows triggered by pull requests from forks, so a public repo's PR builds must be able to pass without them. And there's no way to print a secret to debug it (masking sees to that), so verify a value by using it, not by echoing it.

6

Speeding Things Up: Caching and Artifacts

Because every job starts on a clean machine, every job re-downloads every dependency unless you cache them. The cache key controls when a cache is reused versus rebuilt:

- name: Cache NuGet packages
  uses: actions/cache@v4
  with:
    path: ~/.nuget/packages
    # hashFiles re-keys the cache whenever package references
    # change; a stale key would happily restore stale packages
    key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
    restore-keys: ${{ runner.os }}-nuget-   # fallback: newest partial match

(For .NET specifically, actions/setup-dotnet can do this itself with cache: true, but it keys the cache off a packages.lock.json file and fails without one, so it only suits projects with lock files enabled. The explicit form above works for any ecosystem, lock file or not.)

Artifacts solve the other gap: files that need to outlive the runner, either for a later job or for a human to download from the run page.

- name: Upload published site
  uses: actions/upload-artifact@v4
  with:
    name: site
    path: publish/

# a later job downloads it by the same name
- uses: actions/download-artifact@v4
  with:
    name: site

Rule of thumb: cache what you can re-download anyway (packages), upload as artifacts what you built (binaries, test reports, a published site). Caches are best-effort and can be evicted; artifacts are the run's actual output.

7

Reference

Key Purpose
name Display name in the Actions tab and on commit status checks.
on Triggering events, with optional branches / paths / tags filters.
jobs.<id>.runs-on Runner image: ubuntu-latest, windows-latest, macos-latest.
jobs.<id>.needs Job dependencies. Without it, jobs run in parallel.
steps[].uses / run A published action (pin a version with @vN) or shell commands.
steps[].with / env Inputs to an action / environment variables for a step.
steps[].if Condition for one step, e.g. if: github.ref == 'refs/heads/main'.
permissions Scopes granted to the automatic GITHUB_TOKEN.
concurrency Groups runs so a newer one can supersede an older one: group: ${{ github.ref }} with cancel-in-progress: true stops burning minutes on outdated pushes.
${{ ... }} Expressions: secrets.*, vars.*, github.* event context, functions like hashFiles().