GitHub Actions: CI/CD Workflow Guide

GitHub Actions runs your build, tests, and deployments on GitHub's servers every time something happens in your repository. This guide walks through the moving parts, then builds up a real .NET build-and-test pipeline with the triggers, secrets, and caching you'll actually use.

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@v4

      # 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@v4

      - name: Set up .NET
        uses: actions/setup-dotnet@v4
        with:                          # with: passes inputs to an action
          dotnet-version: 8.0.x       # any 8.0 patch — tracks servicing releases

      - 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 — PRs can still merge right past it.

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.

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 — set cache: true and skip the separate step. The explicit form above works for any ecosystem.)

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.
${{ ... }} Expressions: secrets.*, vars.*, github.* event context, functions like hashFiles().