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.
The Moving Parts
Everything in GitHub Actions hangs off five nouns. Once these click, any workflow file on GitHub reads the same way:
| Concept | What 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).
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.
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.
Triggers Worth Knowing
push and pull_request cover CI. Three more triggers
cover almost everything else:
on: schedule: # min hour day month weekday, always in UTC - cron: "0 6 * * 1" # Mondays 06:00 UTC — nightly builds, link checks, stale sweeps
on: workflow_dispatch: inputs: environment: description: "Where to deploy" type: choice options: [ staging, production ] default: staging # read it later as ${{ github.event.inputs.environment }}
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.
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.
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.
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(). |