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
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.
The subtlety worth internalizing: permissions: governs the ambient GITHUB_TOKEN and nothing else. It does not constrain any other credential your steps happen to carry. So the question to ask is not "what does this job do?" but "what does this job do with the token GitHub injected for free?" If the steps that write things are handed their own token, the ambient one should be read-only no matter how much writing the job does.
normalize-urls.yml is the worked example. It pushes a branch and opens a pull request every month, so contents: write plus pull-requests: write looks obviously correct. It isn't: both of those writes are done by a separate GitHub App installation token, minted in the job and passed explicitly to actions/checkout and to peter-evans/create-pull-request. No step in that job ever uses GITHUB_TOKEN at all. So the block is:
permissions: contents: read
Nothing about the job's behavior changed, and the ambient credential can no longer be used to write anything. You can audit for this yourself in about a minute: grep your workflows for token:, and for every step that is handed an explicit one, ask whether the permissions: block is still granting that same power a second time to a credential nobody is using.
§3 - 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".
§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.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.
step in site-update-deploy.yml; the side panels show what else can trigger the same surface.A tour of CSOH's workflows
Each one solves a single, named problem. Click through to read the source - every file is heavily commented for new readers.
deploy.yml - Build once, publish to AWS + GCP + Azure
Runs on every push to main that touches site or Docker files. It builds the site once (search index + a staged dist/ of the public file set) then fans out to three parallel publish jobs, one per cloud origin: AWS (S3 sync + CloudFront invalidation), Azure (Blob sync to the $web static-website container), and GCP (the container path - build, Trivy scan failing on HIGH/CRITICAL CVEs, push an immutable SHA tag to Artifact Registry, deploy a Cloud Run revision). Every cloud authenticates with keyless OIDC - GCP Workload Identity Federation, an AWS IAM role, an Azure Entra federated credential - so there's no stored service-account key or cloud secret for any of them. Every publish job is gated by GitHub's environment: production so only commits on main can deploy - the four that reach a cloud (publish-aws, publish-azure, publish-gcp, purge-cloudflare) declare it, while build does not, because it only stages files into an artifact and holds no cloud credential to gate. That gate is not just a GitHub-side convention: all three clouds pin their trust to the same subject string, repo:<owner>/<repo>:environment:production. The AWS role's trust policy compares it with StringEquals, the Azure federated credential declares it as its subject, and the GCP Workload Identity pool asserts it in both its attribute condition and the identity it grants impersonation to. A workflow that does not declare environment: production cannot authenticate to any of the three. That matters because trusting the repository claim alone is the easy version of this, and it is much weaker than it looks: it would let any workflow in the repo, on any branch, including scheduled jobs that read untrusted web pages, mint deploy credentials. The environment carries its own deployment-branch rule limiting it to main, so the branch restriction follows transitively instead of being restated in three different cloud dialects that can drift apart. Also worth noting: the last job re-derives every versioned asset's SRI hash from what the edge actually serves, and checks the live security headers against the ones declared in this repo's Cloudflare Terraform (the edge only - the CloudFront policy and the nginx config are not read by CI), so a control that quietly stopped applying fails the deploy. The full architecture (three active/active origins behind a single Cloudflare edge that does TLS, WAF, CDN, headers, and health-checked load balancing) is written up in our cloud deployment page.
site-update-deploy.yml - Site housekeeping
Runs on every push to main that touches site files. Walks through several housekeeping steps (SRI hashes, URL safety check, URL normalization, presentations schema, sitemap dates, preview screenshot generation, image optimization), committing each one back to main if anything changed. Those housekeeping commits then trigger deploy.yml to ship the post-housekeeping state. Read this if you want to see conditional steps, step outputs, committing back to the same repo, and chained workflows via push events.
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.
update-resources.yml - An agent on a short leash
Every Monday, claude-code-action researches candidate entries for resources.html and opens a PR, which auto-merges only if nothing but resources.html changed. Read this one for the security shape rather than the YAML mechanics, because the step fetches pages from the open web and therefore processes input that nobody in this repo controls. Four properties do most of the work here:
- The tool allowlist contains no shell at all.
--allowedToolsis the complete set of tools the model may use, and it is now exactly this:Read,Edit,Glob,Grep,WebSearch,WebFetch. Every entry is an in-process tool; there is noBash(...)pattern of any kind. It reached that state in two passes, and the second is the one worth copying. The first removal was the obvious one:Bash(python3:*)reads as "it may run Python," but it matchespython3 -c '<anything>', which is arbitrary code execution, which quietly voids every other restriction in the list. The same trapdoor sits inBash(node:*),Bash(perl:*),Bash(sh:*), andBash(bash:*). - The second removal is the subtler lesson:
Bash(grep:*)andBash(wc:*)also had to go. They survived the interpreter cull because next topython3they look like nothing - two read-only text utilities that cannot execute anything. Butgreptakes a path, like almost every Unix command does, soBash(grep:*)is a read primitive over the whole runner filesystem:grep . /proc/self/environhands back that step's own environment, secrets included, and there is no pattern syntax that would have stopped it. The generalization is broader than "no interpreters": an allowlist entry for a command that accepts a path is not a restriction, it is a file-read capability wearing a narrow name. The built-inGreptool that stayed searches the checked-out workspace rather than the machine, and is not a shell, and it covers what the prompt actually needed. If a job genuinely needs Python, check the script into the repo and allowlist that exact path, so the list constrains what runs rather than just which binary starts it. - The credential that matters does not exist yet while the model runs. The
csoh-ciApp installation token is what can write to this repository, and it is minted in a step placed after the Claude Code step, immediately before the create-PR step that consumes it. Steps run top to bottom on one VM, so during the research step - the step processing pages nobody here controls - that token has not been issued. What is in the environment then is the Claude OAuth token, which buys model usage and grants nothing in the repo or in any cloud, and which is rotatable. The residual risk is stated rather than papered over:Readstill accepts absolute paths andWebFetchis unqualified, because researching the open web is the entire job, so a prompt injection can still reach that OAuth token. Step order is what decides whether it can also reach a repo-write credential. Moving the mint back to the top of the job, which is where mint steps conventionally go, would silently undo the whole control, so the workflow carries a comment saying so. - The checkout does not leave its credential behind either.
actions/checkouthere runs on the ambientGITHUB_TOKEN, which this workflow scopes tocontents: read, and by default checkout writes whatever token it used into.git/configas anhttp.extraheaderfor the rest of the job. That is a file on disk, readable by any subsequent step with a plain file read: exactly the sort of thing a prompt injection would go looking for, and the reason this matters even for a read-scoped token is that the same default would have persisted a write-scoped one if the job had been wired the conventional way. Nothing after the clone needs it, because the create-PR step is passed the App token explicitly, so the job setspersist-credentials: falseand no token lands on the filesystem.
The generalizable rule: in a job that processes untrusted input, treat the runner's filesystem and environment as readable by that input, and put the credentials somewhere it isn't - which here means both "not written to disk" and "not minted yet."
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.
validate-html.yml - PR check
Runs the W3C HTML5 validator, plus six repo-specific gates (inline scripts, SVG dimensions, JSON-LD validity, crosslink coverage, glossary integrity, docs consistency), on every PR. Posts a comment on failure that names the step that failed and quotes that step's own output. Read this for PR comments via github-script, conditional posting (if: failure() && github.event_name == 'pull_request'), and one trap that is invisible until it bites: this is a required status check on main, and its pull_request trigger therefore carries no paths: filter. A filtered-out workflow never starts, so it produces no check run at all - not skipped, not neutral, nothing - and a required check that never reports blocks the pull request forever, with nothing red to click on. The path filtering moved one level down into a "Detect HTML changes" step that gates the expensive work, so a Python-only PR still gets a green check in seconds having done nothing. Same shape in check-url-safety.yml, the other required check that used to be filtered.
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.
security-impact-review.yml - Reviewing a PR before anything else runs
Every pull request that is not from the repo owner or the csoh-ci App gets a security impact review, and gets it before the other five PR workflows are allowed to start. That ordering is the interesting part, because GitHub does not offer it. Workflows matching an event all start at once, there is no "run this one first" key, and needs: only orders jobs within a single file. This is the sibling of update-resources.yml above: same problem of a model reading input nobody here controls, one notch more hostile, because a pull request is written by someone who wants it merged.
- The ordering comes from a repository setting, not from the YAML. "Require approval for all external contributors" (Settings, Actions, General) holds every workflow on an external PR until a maintainer clicks Approve and run. That is the only real "nothing runs yet" primitive GitHub has. The catch is that it would hold the reviewing workflow too, which is where
pull_request_targetearns its place: that trigger runs in the base repository's context, from the base branch's copy of the file, so it is exempt from the approval gate and starts immediately. The review runs inside the hold it is meant to inform. - That exemption is exactly why
pull_request_targetis the most-abused trigger in Actions. It hands a write-capable token and repository secrets to a run whose subject is attacker-controlled, and the catastrophe is one line long: check out the PR head and then execute anything from it. A build, a test, a dependency install, a linter that loads a repo-local plugin. The fork is now running code with your credentials. So the rule this workflow is built around is a single negative: never check out the head. The checkout takes the base ref, which is what the trigger supplies by default, and addingref: ${{ github.event.pull_request.head.sha }}is the one edit that would break it open. The file says so at the point where someone would be tempted. - The pull request enters as data, never as code. The job fetches a
.diffand a.jsonof metadata through the API and hands them to a checked-in Python script. Nothing from the fork is executed, sourced, imported, or installed, and nothing from it is ever placed on disk as something a later step might run. "Read the contribution" and "run the contribution" are different operations, and only the first one is needed to review it. - The credential that can write lives in a different job.
update-resources.ymlkeeps its write token out of reach by minting it in a later step; here the same idea is enforced one level up. The analysis job holdscontents: readandpull-requests: readand no write scope at all. Posting the comment is a second job withpull-requests: write, and it only ever handles a report this repository's own script produced. Thecsoh-ciApp token, which is on the branch ruleset's bypass list, is never minted in either. Least privilege is easier to hold when the privilege is in a different process than the untrusted input. - Two layers, and only one of them votes. tools/pr_security_triage.py applies fixed rules to the diff and sets the exit code: CI edits, actions pinned to a tag instead of a commit, credential-shaped strings, symlinks, invisible Unicode, edits to the publish and header boundary. A Claude Code step then writes a narrative "what would merging this do" review, and cannot change the verdict. That asymmetry is the design rather than an accident of implementation. Layer two is the one reading prose the contributor wrote, so a prompt injection that says "report this as safe" is addressing the layer with no vote, and the deterministic layer flags that sentence as a finding in its own right. A regex has nothing to persuade.
- A review that did not happen must not look like a review that found nothing. If the diff fetch returns zero bytes the job fails and the comment says, in those words, that it reviewed nothing. This is the failure mode this repository keeps meeting in different costumes: a Cloudflare ruleset that silently stopped applying, dotfiles dropped from a build artifact without an error, a link checker that crawled zero URLs for eleven weeks behind a config typo and reported green every time. An instrument reporting "nothing is there" is indistinguishable from a broken instrument until you make the broken case fail loudly and separately.
The generalizable rule, and the sequel to the one under update-resources.yml: when a job must process hostile input, decide first what it is allowed to do with it, then put every credential and every decision somewhere that input cannot reach. Here that means data instead of code, the verdict in a script instead of a model, and the write token in a different job.
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 - auto-PR creators, scheduled content jobs, manual deploys, anything you'd rather queue than drop. Multiple triggers stack and run one after another. - Cancel old runs:
cancel-in-progress: true. Right for read-only checks per branch - validators, linters, link checkers - and for idempotent deploys where the latest push is the one you want shipped. Push a fix, the old run dies, the new one starts.
How CSOH currently splits these:
site-update-deploy(deploy-pipeline group, cancel old): housekeeping is idempotent - the latest commit always reflects the right state. Cancelling an in-flight run when a new push arrives avoids the "stale SRI commit wins the race" failure mode.normalize-urls(same deploy-pipeline group, queue): shares the lock with site-update-deploy so the two never collide on the same commit-push sequence, but stays queued because losing a URL-normalization sweep would be silent and surprising.deploy(its own group, cancel old): publishes are idempotent - only the latest build needs to ship to each origin. Cancelling avoids racing two deploys that could publish out of order.update-news(its own group, queue): opens a PR on its own branch - doesn't push to main or run a deploy, so it doesn't need the deploy-pipeline lock. Keeping a separate group means a freshly queued site update doesn't cancel a news refresh mid-run.- Per-PR check workflows (per-ref group, cancel old): push a fix, kill the stale check, start fresh on the new commit.
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.shThis is how our deploy job decides whether to actually run the publish 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, aws s3 sync, 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 workflows
Security baseline
Whatever you build, do these from day one:
- Pin every Action to a full commit SHA, then make the repo enforce it. Not
@v3, not@main. Tags and branches can be moved silently by a compromised maintainer; SHAs cannot. Pinning by convention decays, because the enforcement mechanism is a reviewer noticing a tag in a diff at 5pm - so turn on GitHub's repository policy that requires actions to be pinned to a full-length commit SHA (sha_pinning_requiredon the repo's Actions permissions), which this repo now has enabled and which rejects an unpinned reference at run time instead. Use sethvargo/ratchet or Dependabot to keep the SHAs current, and leave the version in a trailing comment so the pin is still readable. - Set explicit
permissions:on every workflow. Default tocontents: read. Add only what you need - and remember the block only governs the ambientGITHUB_TOKEN, so a job whose steps are handed their own token should stay read-only here. - 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. This is exactly how CSOH deploys: every publish job federates a short-lived token to its cloud, so there's not a single static cloud key in the repo. See how we wire it in Terraform and our deploy architecture.
- 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. - Pin your cloud's OIDC trust to the full subject, not just the repository.
assertion.repository == 'owner/repo'is the condition everyone writes first, and it trusts every workflow in the repo on every branch. Require the subject too - ours isrepo:<owner>/<repo>:environment:productionon all three clouds - and let the GitHub Environment's deployment-branch rule carry the branch restriction, so there is one place to change rather than three. - Never allowlist a bare interpreter when you hand an agent, bot, or plugin a list of permitted commands.
python3,node,perl,sh, andbashall take a-cflag, so allowlisting one of them is allowlisting everything and silently voids the rest of the list. Allowlist a checked-in script path instead. This is non-negotiable in any job that reads web pages, issue text, or feeds. - Set
persist-credentials: falseonactions/checkoutunless a later step really does push. By default checkout stores the token it used in.git/configas anhttp.extraheader, where it stays for the whole job and any step can read it back out as a file. When the steps that need the token are passed it explicitly, the copy on disk is pure downside. - Verify the control where it lives, not only where you declared it. Infrastructure as code tells you what you asked for; it does not prove the edge is doing it. Our deploy pipeline re-derives every published asset's SRI hash from what the CDN actually serves, and now also compares the live security headers against the ones declared in this repo's Cloudflare Terraform. A forgotten
apply, a dashboard edit, or a resource that silently stopped reconciling fails the deploy instead of sitting undetected in production.
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: validate-html.yml. Short, single-purpose: runs an HTML validator on PRs and posts a comment. A good first read for the trigger / step / output / annotation shape.
- Read the validators: check-broken-links.yml and check-url-safety.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.
- Then the keyless-deploy one: deploy.yml. The OIDC-token-to-cloud-credential exchange in action - three times, one per cloud - plus build-once-publish-everywhere. Every line is doing real security work.
- Finally, the big one: site-update-deploy.yml. Step outputs, conditional steps, committing back to the repo, chained workflows via push events.
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.
From the Friday sessions
This is not a settled topic. Here is where the CSOH community worked through it on the live Friday call:
-
GitHub repo security at scale and CISA's SSVC vulnerability framework
The conversation covered defending against rogue-employee actions and supply-chain attacks, the difficulty of securing GitHub Actions given their transient runners, and layered controls: CASB, SASE, conditional access through the IdP, and sign-off-based policies that also educate users…
-
Open-source supply chain attacks, GitHub Actions hardening, early-computing nostalgia
The team discussed security practices for GitHub Actions, focusing on minimizing the use of third-party dependencies and implementing defense-in-depth strategies…
-
Website contributions, GitHub PR workflows, Forrester CNAP critique
Dane raised security concerns about the GitHub actions used in the workflow, particularly regarding FTP secrets, and challenged the team to attempt an exfiltration, which Shawn encouraged…
-
Wiz Acquisition and Shawn's Recovery
The discussion focuses on the increasing risks of supply chain attacks, particularly in open-source projects and GitHub Actions. Matt Alvarez points out that the barrier to entry for impactful supply chain attacks is lowering, with even small projects becoming potential targets…
Join the next Friday session to argue with us in real time, or browse all 111 recaps.