The honest version: GitHub Actions is the friendliest CI/CD platform you'll meet. It runs a script when something happens in your repo — a push, a pull request, a scheduled time, or a button click. That's it. Everything else is configuration. The docs are good but vast; the fastest way to actually get Actions is to read someone else's working YAML and trace what each line does. That's what this page is — every concept comes with a "go look at line N of this file in our repo" pointer.
What you'll need: a GitHub repository, comfort with git basics, and willingness to read shell. No prior CI/CD experience required.
📖 On this page
What GitHub Actions actually is
GitHub Actions is GitHub's built-in automation runner. You drop a YAML file under .github/workflows/, and GitHub watches your repository for the events you list — a push to main, a new pull request, a cron schedule, a manual button click. When a matching event fires, GitHub spins up a fresh virtual machine ("runner"), runs the steps you wrote, and tears it down.
Every workflow file boils down to:
- name — what to call this workflow in the UI
- on — what events trigger it
- jobs — one or more named bundles of steps
- steps — what to actually do (run a script, use a published Action, set an output)
Here's the absolute minimum:
name: Hello
on:
push:
jobs:
greet:
runs-on: ubuntu-latest
steps:
- run: echo "Hi, $GITHUB_ACTOR pushed to $GITHUB_REF"Drop that at .github/workflows/hello.yml and every push to your repo will produce a 5-second run that prints your username. From there, the surface area expands fast — but the shape stays the same.
Why it's cool (and where it isn't)
What's good
- Zero infrastructure to run. No Jenkins server, no agents to patch, no SSH keys to rotate. You write YAML; GitHub gives you fresh VMs.
- Free for public repos, generous for private. Public repos get unlimited Linux minutes. Private gets a meaningful free tier for small teams.
- Marketplace ecosystem. 20,000+ pre-built Actions for common tasks (checkout, build, deploy, sign artifacts, post Slack messages). Most are 2–3 lines to use.
- Tight integration. Actions read your code, comment on PRs, set commit statuses, and modify the repo as a first-class citizen — no external bot infrastructure.
- Schedules. Cron-on-the-internet, free. We use this to refresh news every 3 hours and run weekly link checks.
- Inspectable. Every run, every step, every line of output is logged and downloadable. The full history of "did our deploy succeed?" is there forever.
Where it isn't
- YAML. Whitespace-sensitive, easy to break, no compile-time error for most mistakes — you push, you wait, you find out it didn't fire.
- Hard to test locally. Tools like act exist but only approximate the real environment. The real test is "push and watch."
- Slow feedback for small fixes. A typo in
if: conditionis a 30-second wait per attempt to verify. - The free tier is metered for private repos. Heavy CI use can rack up minutes; macOS and Windows runners cost more than Linux.
- Some footguns are sharp.
pull_request_target+ secrets is the canonical way to leak credentials to a malicious PR. We'll cover safer patterns below.
Anatomy of a workflow file
Open .github/workflows/validate-html.yml in another tab and follow along. It's short and exhibits most of the moving parts.
Triggers (on:)
This workflow runs in three situations:
on:
pull_request:
paths:
- '**.html'
schedule:
- cron: '0 7 * * 1'
workflow_dispatch:Translation: when a PR touches any HTML file, every Monday at 07:00 UTC, or whenever someone clicks "Run workflow." The paths filter is the secret to a fast CI — if you only validate HTML, don't run on CSS-only PRs.
Permissions
permissions: contents: read pull-requests: write
Default GitHub-Actions permissions are broader than they need to be. We pin every workflow to the minimum: read the code, post PR comments, nothing else. If a malicious dependency ever lands in one of our Actions, it can't push to the repo because we never gave it that scope.
Concurrency
concurrency:
group: validate-html-${{ github.ref }}
cancel-in-progress: trueRuns of this workflow on the same git ref (the same PR or branch) cancel each other. Push a fix, the in-flight validation stops and a fresh one starts on the new commit. Different PRs run in parallel because the group includes ${{ github.ref }}. We learned this lesson the hard way — see "Concepts that bite newcomers".
Jobs and steps
jobs:
validate-html:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Validate HTML5
uses: Cyb3r-Jak3/html5validator-action@443b108eb8e134b63a1f8a8ba0c942d552608ed7 # master 2025-09-19
with:
root: .
blacklist: google66d489593949bd4c.htmlTwo important habits visible here:
- Pin Actions to a full SHA, not a tag. The trailing comment is the version for humans; the SHA is what actually runs. Tags can be moved silently. SHAs cannot. The Dependabot bumps these for us.
- Name every step.
name:is what shows up in the run UI. Skip it and your run log is full of "Run actions/checkout@…" lines that nobody can grep.
A tour of CSOH's seven workflows
Each one solves a single, named problem. Click through to read the source — every file is heavily commented for new readers.
site-update-deploy.yml — The big one
Runs on every push to main that touches site files. Walks through ten housekeeping steps (SRI hashes, URL safety check, URL normalization, presentations schema, sitemap dates, preview screenshot generation, image optimization), commits each one back to main if anything changed, then mirrors the whole site to FTP. Read this if you want to see conditional steps, step outputs, committing back to the same repo, and FTP deploy via lftp.
update-news.yml — Scheduled content updates
Every 3 hours, refreshes news.html from configured RSS/Atom feeds, opens a PR with the changes, auto-approves it (using a separate bot account so we don't violate "can't approve your own PR"), and auto-merges if only news files were touched. Read this if you want to see cron schedules, auto-PR creation, and the two-PAT pattern.
normalize-urls.yml — Monthly maintenance
Once a month, strips tracking parameters and resolves redirects on every link, then opens a PR for human review. Auto-approved but not auto-merged because cross-domain redirects deserve eyes. Good example of auto-PR with mandatory human gate.
manual-deploy.yml — On-demand deploy
Pure workflow_dispatch. Force-pushes the entire site to FTP when you click "Run workflow." Useful for "the auto-deploy skipped this push and I need it live now." Smallest workflow — good for reading the deploy plumbing without distractions.
validate-html.yml — PR check
Runs the W3C HTML5 validator on every HTML-touching PR. Posts a comment on failure with the first 50 lines of the error log. Read this for PR comments via github-script and conditional posting (if: failure() && github.event_name == 'pull_request').
check-broken-links.yml — External link rot
Crawls every link in the site with lychee, weekly and per-PR. Caches HTTP responses for 3 days so it's fast. Read this for cache restoration and a non-blocking check (fail: false) — link rot is everywhere and shouldn't gate merges.
check-url-safety.yml — Malware/phishing check
Validates every external URL against safety lists. Runs on PRs and weekly. Hard fails the workflow if anything unsafe is found and posts a summary comment. Sister workflow to broken-links.
Concepts that bite newcomers
1. GITHUB_TOKEN vs Personal Access Token (PAT)
Every workflow run gets an automatic, scoped GITHUB_TOKEN — usable for most repo operations, but commits made with it do not trigger downstream workflows. That's a deliberate anti-loop measure. We use a Personal Access Token (PAT_TOKEN) when we want a bot-pushed commit to trigger another workflow — for example, when update-news opens a PR that needs validate-html and check-broken-links to run on it.
If your bot's commits are mysteriously not triggering CI: you're using GITHUB_TOKEN and need a PAT.
2. PATs need workflow scope to push workflow files
This bit us in late April 2026. GitHub blocks any PAT push that touches .github/workflows/* unless the PAT carries the workflow scope — a security guardrail that prevents a leaked PAT from rewriting CI to deploy malicious code. The catch: the check runs against every commit in the push range, not just the bot's own commit. So once main has any workflow file changes, every subsequent bot-driven force-push of a derived branch will fail until the PAT has workflow scope.
Lesson: when you create your PAT_TOKEN, give it workflow scope from day one. Otherwise the day you edit a workflow file, your bots break.
3. Concurrency: queue, cancel, or both?
Two common patterns, and getting them mixed up causes either lost runs or lost time:
- Queue (don't cancel):
cancel-in-progress: false. Right for things you can't lose — deploys, content commits, anything that mutates state. Multiple triggers stack and run one after another. CSOH's deploy-pipeline group works this way. - Cancel old runs:
cancel-in-progress: true. Right for read-only checks per branch — validators, linters, link checkers. Push a fix, the old run dies, the new one starts. Saves time.
Mistake we made: at first, our deploy workflow had no concurrency at all. Two news-update commits within a minute spawned two simultaneous deploys that interleaved their commits and corrupted the deployed site.
4. git push from a runner can race with concurrent runs
Even with concurrency groups, you can race against other workflows in different groups (or against humans). A safer push pattern:
git push || (git pull --rebase origin main && git push)
If the first push is rejected because main moved, rebase on top of the latest main and retry. We use this in every commit-and-push step in site-update-deploy.yml.
5. Step outputs and conditionals
Steps can publish small key/value outputs that later steps in the same job can read:
- name: Did anything change?
id: detect
run: |
if git diff --quiet; then
echo "changed=false" >> $GITHUB_OUTPUT
else
echo "changed=true" >> $GITHUB_OUTPUT
fi
- name: Deploy
if: steps.detect.outputs.changed == 'true'
run: ./deploy.shThis is how our deploy job decides whether to actually run the FTP step at all — if no SRI hashes, no sitemap, no previews, no HTML changed, the whole deploy is skipped.
6. if: always() vs if: failure()
By default, a step skips if any earlier step failed. if: always() forces it to run anyway — handy for uploading a debug log. if: failure() only runs the step because something earlier failed — handy for posting "your build broke" comments. We use both extensively in our PR-check workflows.
7. Artifacts: download for offline debugging
actions/upload-artifact stuffs files into per-run storage that you can download from the run page. Critical for any step that produces a report — we use it for the safety-scan output, the link-rot crawl, the HTML validator log. retention-days: 30 keeps your storage usage bounded.
8. Secrets are write-only and masked
Anything in secrets.X is automatically masked in run logs (printed as ***). Never echo a secret to debug it — even if you wrote the workflow yourself, the masking still applies. To see a secret, you have to use it. Never put secrets in the YAML directly; always reference them by name.
Security baseline
Whatever you build, do these from day one:
- Pin every Action to a full commit SHA. Not
@v3, not@main. Tags and branches can be moved silently by a compromised maintainer; SHAs cannot. Use sethvargo/ratchet or Dependabot to keep the SHAs current. - Set explicit
permissions:on every workflow. Default tocontents: read. Add only what you need. - Avoid
pull_request_targetunless you fully understand it. It runs in the context of the base branch with full secrets, on code from a fork. Footgun. The breach kill chains page covers an attack pattern that exploited this on a major OSS project. - Don't echo secrets, don't log them, don't write them to artifacts. Even if it's "just a debug step you'll remove later."
- Use OIDC for cloud credentials when you can. AWS, GCP, and Azure all support short-lived federated tokens via OIDC — no long-lived access keys to leak. We don't use this on CSOH (FTP-based deploy), but for any cloud deploy this is the modern default.
- Treat
${{ github.event.* }}as untrusted input when interpolating into shell. PR titles, branch names, and commit messages can contain attacker-controlled strings. Use${VAR}fromenv:instead of inline${{ }}in run blocks.
Using our repo as a learning resource
The whole point of this page is that our repo is a working, in-production reference. A suggested reading order:
- Start with the smallest: manual-deploy.yml. ~90 lines, one job, one trigger, demonstrates the FTP deploy plumbing without distractions.
- Read the validators: validate-html.yml and check-broken-links.yml. PR-comment patterns, caching, and the read-only check shape.
- Then the auto-PR ones: update-news.yml and normalize-urls.yml. Scheduling, two-PAT auto-approve dance, conditional auto-merge.
- Finally, the big one: site-update-deploy.yml. Step outputs, conditional steps, committing back to the repo, deploy gating, FTP mirroring with retry.
Every workflow file in our repo is heavily commented — every non-obvious line has a 1–3 line explanation aimed at someone who has never written GitHub Actions before.
You can fork the repo, gut the content, keep the workflow scaffolding, and have a working static-site CI/CD in an hour. We'd love it if you did. Send us a link in the Friday Zoom.
Further reading
- GitHub Actions documentation — the official source of truth, well-organized.
- Security hardening for GitHub Actions — required reading. Bookmark and re-read.
- Contexts and expressions — what
${{ github.x }},${{ steps.x.y }},${{ secrets.X }}actually evaluate to. - Actions Marketplace — pre-built actions for most common tasks. Always check the source before using.
- ratchet — pin tags to SHAs automatically.
- act — run GitHub Actions workflows locally for faster iteration.
- StepSecurity — opinionated tooling for hardening Actions across an org.
Questions?
Bring them to Friday Zoom. We've got several practitioners who run nontrivial Actions setups (auto-deploy, signed artifacts, OIDC to AWS/GCP) and are happy to walk through specifics. The meeting recaps often surface CI/CD horror stories worth learning from.