Cloud Security Office Hours Banner

How We Use GitHub Actions

GitHub Actions explained against real, working production code — every concept tied to a specific line in CSOH's own workflows. If you learn better by reading other people's YAML than by reading the docs, this is for you.

Browse the Workflows Take the Tour

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

  1. What GitHub Actions actually is
  2. Why it's cool (and where it isn't)
  3. Anatomy of a workflow file
  4. A tour of CSOH's seven workflows
  5. Concepts that bite newcomers
  6. Security baseline
  7. Using our repo as a learning resource
  8. Further reading

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:

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

Where it isn't

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: true

Runs 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.html

Two important habits visible here:

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:

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.sh

This 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:

  1. 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.
  2. Set explicit permissions: on every workflow. Default to contents: read. Add only what you need.
  3. Avoid pull_request_target unless 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.
  4. 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."
  5. 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.
  6. Treat ${{ github.event.* }} as untrusted input when interpolating into shell. PR titles, branch names, and commit messages can contain attacker-controlled strings. Use ${VAR} from env: 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:

  1. Start with the smallest: manual-deploy.yml. ~90 lines, one job, one trigger, demonstrates the FTP deploy plumbing without distractions.
  2. Read the validators: validate-html.yml and check-broken-links.yml. PR-comment patterns, caching, and the read-only check shape.
  3. Then the auto-PR ones: update-news.yml and normalize-urls.yml. Scheduling, two-PAT auto-approve dance, conditional auto-merge.
  4. 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

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.