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.

ยท ยท Vendor-neutral ยท View source on GitHub

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 nine 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

Anatomy of a GitHub Actions workflow A workflow is triggered by an event, defines one or more jobs, each composed of steps that run on a runner. From trigger to artifact โ€” what GitHub Actions actually does on: Trigger push, PR, schedule, workflow_dispatch jobs: Workflow runs-on, permissions, env, concurrency job: build checkout โ†’ install โ†’ test โ†’ upload job: validate html5validator job: deploy needs: build, validate runner ubuntu-latest Ephemeral VM with GITHUB_TOKEN scope artifacts & outputs (uploaded back to GitHub) Logs ยท Test reports ยท Build artifacts ยท Pages deploy ยท Status checks on the PR Outputs from one job can feed into the next via the needs graph.
Trigger โ†’ workflow โ†’ parallel jobs on ephemeral runners โ†’ artifacts uploaded back. The needs graph (here: deploy depends on build + validate) is how you sequence work.

Open .github/workflows/validate-html.yml in another tab and follow along. It's short and exhibits most of the moving parts.

Tip: the workflow file has matching ยง1โ€“ยง4 markers in its comments. Ctrl/Cmd+F for ยง3 in the file to jump straight to the section we're discussing here.

ยง1 โ€” 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.

ยง2 โ€” 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.

ยง3 โ€” 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".

ยง4 โ€” 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:

DevSecOps shift-left timeline Six SDLC stages from Plan to Monitor with the security controls that fit at each stage. Earlier in the pipeline = cheaper to fix. Shift-left โ€” bug found in Plan costs $1, found in Monitor costs $1,000 CHEAPER TO FIX EXPENSIVE TO FIX PLAN design / model Threat model Trust boundaries Data classification STRIDE PASTA CODE developer IDE SAST in IDE Pre-commit hooks Secrets scan Semgrep gitleaks BUILD CI pipeline Dep scanning SBOM generation Image hardening Dependabot Trivy / Grype TEST staging gates DAST / fuzz IaC scan License check Checkov tfsec DEPLOY to cloud Signed artifacts Admission control Drift detection cosign / sigstore OPA / Gatekeeper MONITOR production Runtime CSPM CDR / EDR Anomaly detect CloudTrail / SIEM GuardDuty / Falco A misconfig caught at Plan is a comment in a doc; the same misconfig caught at Monitor is an incident retro.
Every control should run as early in the pipeline as it can โ€” but the late-stage controls don't go away, they catch what slipped through.
CSOH’s GitHub Actions deploy pipeline End-to-end flow from a developer’s git push through path filtering, concurrency locking, sequential build steps, validation, FTP mirror, and final deploy to production. From git push to live on csoh.org โ€” what actually happens git push origin main developer commits land on the default branch Did paths match? *.html, style.css, main.js, .htaccess, โ€ฆ no → skip deploy CONCURRENCY GATE group: deploy-pipeline queues serially with normalize-urls + manual-deploy Checkout repository (PAT-authed) actions/checkout โ€” full history for SRI diff Update SRI integrity hashes tools/update_sri.py โ€” rewrites all *.html if css/js changed Generate preview images img/previews โ€” for new pages Normalize URLs (apply) tools/normalize_urls.py --apply strips utm_*, upgrades httpโ†’https, follows redirects URL safety check (gate) unsafe destination → halt deploy Commit + push any changes back [skip ci] โ€” avoids loop lftp mirror → /public_html/ 5 passes: site files ยท previews ยท news-banners ยท OG images ยท author photos ยท icons ยท photos + chat-screenshots if any new ones ๐ŸŒ LIVE ON CSOH.ORG total pipeline time: ~2-3 minutes Other triggers PR opened/updated โ†’ lint.yml + validate-html.yml Daily cron 8am UTC โ†’ update-news.yml Monthly cron 1st โ†’ normalize-urls.yml (PR) workflow_dispatch . Why this order matters SRI hashes are derived from the final css/js, so they run before previews and URL fixes. URL normalization rewrites links, so its safety check runs before any commit-back. Concurrency group prevents races between this pipeline, manual-deploy, and the monthly normalize PR.
This pipeline replaces a traditional CI server. Every box is a real step in site-update-deploy.yml; the side panels show what else can trigger the same surface.

A tour of CSOH's nine workflows

Each one solves a single, named problem. Click through to read the source โ€” every file is heavily commented for new readers.

gcp-deploy.yml โ€” Container build, scan, deploy to Google Cloud Run

Runs on every push to main that touches site or Docker files. Authenticates to GCP via Workload Identity Federation โ€” no service account JSON key, just a short-lived OIDC token GitHub mints for the run and exchanges for a 1-hour GCP access token (the WIF policy only accepts tokens claiming this exact repo). Builds the container, scans it with Trivy failing on HIGH/CRITICAL CVEs, pushes to Artifact Registry with an immutable SHA-based tag, then deploys a new Cloud Run revision pinned to that SHA. Gated by GitHub's environment: production so only commits on main can deploy. The full architecture (Cloud Run โ†’ HTTPS LB with Cloud Armor WAF + Cloud CDN โ†’ managed TLS) is written up in our cloud deployment page.

site-update-deploy.yml โ€” Housekeeping + legacy FTP deploy

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 only the changed files via FTPS to the legacy LiteSpeed host. Read this if you want to see conditional steps, step outputs, committing back to the same repo, incremental FTP deploys via mtime restoration, and lftp with timeout safety. The FTPS mirror step is being retired now that gcp-deploy.yml serves production from Cloud Run; the housekeeping steps stay.

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 FTP deploy (legacy)

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. Being retired alongside the FTPS path; for forced GCP redeploys, click "Run workflow" on gcp-deploy.yml instead.

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').

lint.yml โ€” Code & workflow lint

Quality gate that runs on every push and PR. Three parallel jobs: actionlint for workflow YAML (with bundled shellcheck on inline run: blocks), ruff for the Python housekeeping scripts, and yamllint for structural YAML sanity. Read this for parallel jobs in one workflow, SHA-pinned third-party actions, and how to relax yamllint's defaults to play nicely with GitHub Actions YAML (see .yamllint.yml).

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:

How CSOH currently splits these:

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. Adding any concurrency group fixed the corruption; getting the queue/cancel split right took a few iterations.

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.

9. actions/checkout resets every file's mtime โ€” incremental deploys break

A fresh checkout gives every file the SAME mtime: the moment it was checked out. If you're using rsync, lftp mirror, or any other tool that decides what to upload by comparing mtimes, every run will re-upload the entire tree. Took us months to notice โ€” the deploy was just slow.

The fix is a one-liner before the deploy step: walk every tracked file and rewind its mtime to the date of its last git commit. Then your housekeeping scripts (which only write files that actually change) generate a fresh "now" mtime on exactly the files that need it, and the deploy uploads only those:

git ls-files -z | while IFS= read -r -d '' file; do
  ts=$(git log -1 --format=%ct -- "$file" 2>/dev/null)
  [ -n "$ts" ] && touch -d "@$ts" "$file"
done

See it in site-update-deploy.yml. Pairs with a discipline rule for housekeeping scripts: only write a file when its content actually changes (if new == old: skip). Together they mean a no-op deploy actually does nothing.

GITHUB_TOKEN is read-only by default for a reason โ€” every workflow that needs more should justify it in a comment. โ€” the rule we apply across CSOH’s eight workflows

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.