Advanced Actions — and Reading a Real Pipeline
You can now write a workflow. This lecture is about writing a good one: secure, fast, and correct under pressure. Then we read this site’s actual deploy pipeline line by line — every key, why it’s there, what breaks if you remove it.
🎯 Learning objectives
By the end you can:
- Use secrets and understand the auto-provided
GITHUB_TOKEN.- Apply least-privilege
permissionsand explain why the default is dangerous.- Explain OIDC (
id-token: write) — passwordless cloud auth — at a high level.- Read contexts and expressions (
${{ ... }}) and theif:conditional.- Speed up pipelines with caching and protect them with concurrency.
- Recognize supply-chain risk and pin third-party actions to a commit SHA.
- Dissect this site’s
deploy.ymlcompletely and confidently change it.
Mental model: from “it runs” to “it runs safely and fast”
A junior writes a workflow that works. A senior writes one that:
- Holds the fewest keys to the kingdom (least privilege).
- Never leaks a password into logs.
- Doesn’t trample itself when two pushes race.
- Reuses cached work instead of redoing it.
- Can be trusted not to run a stranger’s malicious code.
These five concerns are this lecture.
1. Secrets — passwords that never appear in code
You never write a real password in a workflow file (it’s in your repo, visible forever). Instead you store it in Settings → Secrets and variables → Actions, then reference it:
steps:
- name: Deploy to my server
run: ./deploy.sh
env:
API_KEY: ${{ secrets.MY_API_KEY }} # injected at runtime, masked in logs
Secrets are masked
If a secret’s value ever appears in a log, GitHub automatically replaces it with
***. Combined with “never in the file,” this keeps credentials out of both your repo and your run logs.
The free secret you already have: GITHUB_TOKEN
Every workflow run is automatically given a temporary credential called GITHUB_TOKEN. It lets the workflow act on its own repo (post a comment, push to a branch, publish Pages) — and it expires the moment the run ends. You don’t create it; GitHub mints a fresh one per run. Most workflows never need any other secret.
2. Permissions — least privilege
By default that GITHUB_TOKEN can be quite powerful. The professional habit is to grant only what this workflow needs and nothing more:
permissions:
contents: read # can read the repo code
pages: write # can publish to GitHub Pages
id-token: write # can request an OIDC identity token (see below)
Why this matters
If a step runs a compromised third-party action, that code inherits the token’s permissions. A token that can only
readcontents can’t vandalize your repo even if hijacked. Narrow permissions turn a catastrophe into a non-event. Always declare them explicitly instead of relying on the broad default.
3. OIDC — logging into the cloud with no password at all
The old way to deploy to AWS/GCP/Azure: store a long-lived cloud password as a secret. If it leaks, attackers have your cloud — until you notice.
OIDC (OpenID Connect) replaces that. With id-token: write, the runner can request a short-lived, cryptographically-signed identity token that says “I am genuinely a GitHub Actions run from Darshan-1820/Career-sprint.” The cloud provider verifies that signature and hands back temporary access — no stored password anywhere.
permissions:
id-token: write # "let this run prove who it is to a third party"
You’re already using OIDC
GitHub Pages deployment uses exactly this mechanism. That’s why this site’s workflow has
id-token: write— thedeployjob proves its identity to the Pages service to publish, with no password involved. You’ll meet the same pattern when you deploy to AWS later.
4. Contexts & expressions — the ${{ ... }} syntax
${{ ... }} is how a workflow reads live data about itself. Inside the braces you access contexts — bags of information GitHub fills in:
- run: echo "Commit ${{ github.sha }} on branch ${{ github.ref_name }}"
- run: echo "Triggered by ${{ github.actor }}"
| Context | Holds | Example |
|---|---|---|
github | Info about the event/repo | github.sha, github.ref_name, github.actor |
secrets | Your stored secrets | secrets.MY_API_KEY |
env | Environment variables you set | env.NODE_ENV |
steps | Outputs from earlier steps | steps.deployment.outputs.page_url |
runner | Info about the machine | runner.os |
The if: conditional
Expressions let a step or job run only when a condition holds:
- name: Deploy
if: github.ref_name == 'main' # only on the main branch
run: ./deploy.sh
This is how one workflow behaves differently for main vs a feature branch, or skips deploy on a pull request.
5. Caching — stop redoing slow work
Installing dependencies from scratch every run is slow. A cache saves a folder between runs and restores it when its key matches:
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm # cache the npm download cache, keyed on package-lock.json
When package-lock.json is unchanged, the runner restores the cached packages instead of re-downloading them — often cutting minutes off a run. (For non-Node tooling, actions/cache does the same generally.)
Cache is a speedup, never correctness
A workflow must produce the same result whether or not the cache hit. Never store anything in a cache that the build needs to exist — caches can be evicted at any time. Treat them as a bonus, not a dependency. (Recognize this lesson? It’s the same stale-cache trap from the Docusaurus rspack panic doc.)
6. Concurrency — don’t let runs trample each other
If you push twice quickly, two deploys start. Without coordination they can race and publish out of order. Concurrency groups runs and cancels the stale one:
concurrency:
group: pages # all Pages deploys share one lane
cancel-in-progress: true # a new run cancels the older in-flight one
Result: only the newest deploy survives — exactly what you want, since the latest commit is the truth.
7. Matrix builds — same job across many versions (bonus)
A matrix runs one job repeatedly with different variables — e.g. test on three Node versions at once:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm test
This spins up three parallel jobs automatically. Libraries use this to prove they work across versions.
8. Security — the supply-chain risk
Every uses: someorg/their-action@v1 runs someone else’s code on a machine that holds your token. If their account is hacked and they push malicious code to v1, your next run executes it.
# Convenient, but trusts the tag to never change maliciously:
- uses: some-third-party/action@v1
# Hardened — pin to an exact commit SHA that can never be swapped:
- uses: some-third-party/action@a1b2c3d4e5f6... # immutable
The rule of thumb
Official
actions/*actions (maintained by GitHub) →@v4tags are fine. Untrusted third-party actions → pin to a full commit SHA, which is immutable, so the code can never change under you. Combined with least-privilegepermissions, this is how you stay safe using the Marketplace.
9. Debugging a red pipeline
When a run fails:
- Read the failing step — it’s highlighted ❌, and the error is usually the last lines of its log.
- Re-run — “Re-run failed jobs” retries without redoing the green ones. “Re-run with debug logging” adds verbose output.
- Reproduce locally — run the same commands (
npm ci,npm run build) on your machine. If it fails locally too, it’s your code, not Actions. - Check the runner assumption — “works locally” but fails in CI is almost always missing state: an uncommitted file, an env var only your laptop has, or a forgotten
checkout.
The golden debugging question
Ask: “What does my laptop have that the empty runner doesn’t?” The answer is the bug 90% of the time — an installed tool, a local file, an environment variable, a different OS.
🧪 Capstone Lab — dissect this site’s deploy.yml
Here is the actual file that publishes this site, annotated completely. Read every comment — you now know every concept it uses.
name: Deploy to GitHub Pages # human label shown in the Actions tab
on:
push:
branches: [main] # ① EVENT: deploy only when main changes
workflow_dispatch: # ② also allow a manual "Run" button
permissions: # ③ LEAST PRIVILEGE — only what's needed
contents: read # read the repo code
pages: write # publish to GitHub Pages
id-token: write # OIDC: prove identity to Pages (no password)
concurrency: # ④ only the newest deploy survives
group: pages
cancel-in-progress: true
jobs:
build: # ⑤ JOB 1 — compile the static site
runs-on: ubuntu-latest # fresh Ubuntu runner
steps:
- uses: actions/checkout@v4 # pull the repo onto the empty machine
with:
fetch-depth: 0 # full git history (Docusaurus uses it for
# "last updated" dates on docs)
- uses: actions/setup-node@v4 # install Node 20...
with:
node-version: 20
cache: npm # ...and CACHE deps keyed on package-lock.json
- name: Install dependencies
run: npm ci # clean, reproducible install from the lockfile
- name: Build website
run: npm run build # Docusaurus → static files in build/
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: build # ⑥ ARTIFACT: hand build/ to the next job
deploy: # ⑦ JOB 2 — publish what build/ produced
needs: build # ⑧ wait for build to succeed first
runs-on: ubuntu-latest # a DIFFERENT fresh machine (no build files!)
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }} # ⑨ live URL shown in the UI
steps:
- name: Deploy to GitHub Pages
id: deployment # name this step so ⑨ can read its output
uses: actions/deploy-pages@v4 # downloads the artifact + publishes it
Work through these comprehension checks against the file:
- Why does
deployhaveneeds: build? Because they run on separate machines; deploy must wait for build to finish and it relies on the artifact build uploaded. Removeneeds:and deploy races build — publishing nothing or stale files. - Why
upload-pages-artifactin build and nothing obvious downloading it in deploy?actions/deploy-pages@v4downloads the Pages artifact automatically — that’s the cross-job handoff (concept ⑥/⑧ from Lecture 2). - What does removing
id-token: writebreak? The deploy can no longer prove its identity to the Pages service via OIDC → publish fails. (You saw this live: Pages had to be enabled with the workflow source for this token flow to work.) - What does
cancel-in-progress: trueprotect against? Two quick pushes → the older deploy is cancelled so the newest commit wins.
Now change it safely
Because you understand every line, you can confidently improve it — e.g. add a
pull_requesttrigger to thebuildjob only (test PRs without deploying them), or add a link-checker step beforeBuild website. That’s the difference between copying a workflow and owning one.
⚠️ Common misconceptions
Warning
- “The default
GITHUB_TOKENis harmless.” It can be powerful. Always scopepermissions:down.- “Pinning to
@v4is fully safe for any action.” Safe-ish for officialactions/*. For third-party actions, a tag can be moved to malicious code — pin to a commit SHA.- “A cache miss can break my build.” It must not. If your build needs a cache to succeed, you’ve misused caching.
- “Secrets in logs are fine, they’re my logs.” Logs can be public (public repo) or shared. GitHub masks known secrets, but never
echoone deliberately.- “OIDC is just a fancy secret.” No — there’s no stored credential at all. The runner proves identity per-run and gets temporary access. That’s the entire point.
✅ Self-check
1. Why declare `permissions:` explicitly instead of using the default?
Least privilege. If any step (including a third-party action) is compromised, it inherits the token’s powers. Narrow permissions (contents: read) mean a hijacked step can’t damage the repo. Defaults are broader than most workflows need.
2. In one sentence, what does `id-token: write` enable?
It lets the run request a short-lived, signed OIDC identity token to prove which repo/workflow it is, so a service (GitHub Pages, AWS, etc.) can grant temporary access without any stored password.
3. `deploy` fails with "no files to publish," but `build` was green. Likely cause?
The artifact handoff is broken — either build didn’t upload build/ (wrong path:), or deploy isn’t downloading it. Remember: separate machines, so deploy only sees what was uploaded as an artifact.
4. Why is pinning a third-party action to `@v2` riskier than pinning `actions/checkout@v4`?
actions/checkout is maintained by GitHub itself (trusted). A third-party @v2 tag can be repointed by its author (or an attacker who compromises them) to new code that runs with your token. A full commit SHA is immutable and can’t be swapped.
5. You add a 6th Node version to a matrix. How many jobs run now, and in parallel or series?
One job per matrix value, so 6 jobs, running in parallel (subject to your account’s concurrency limits). Matrix = “same job, many variables, simultaneously.”
📚 Graded resources
- 🟢 Beginner — Using secrets in Actions — the official how-to.
- 🟡 Intermediate — Automatic token authentication (
GITHUB_TOKEN) and Permissions forGITHUB_TOKEN. - 🟡 Intermediate — Caching dependencies — keys, restore-keys, eviction.
- 🟡 Intermediate — Reusing workflows —
workflow_calland composite actions, the DRY of CI/CD. - 🔴 Advanced — Security hardening for GitHub Actions — the full threat model: SHA pinning, untrusted input, self-hosted runner risk.
- 🔴 Advanced — About OpenID Connect in Actions — how passwordless cloud auth really works.
Why AI can’t do this for you
AI will happily add
permissions: write-allto make an error “go away” — and quietly hand the keys to the kingdom to every action you run. Judgment calls — how little power to grant, which actions to trust, whether a cache is safe here — require understanding the threat model, not pattern-matching syntax. That judgment is exactly what makes you valuable in a world where everyone can generate YAML.
🔑 Key terms
| Term | Meaning |
|---|---|
| Secret | An encrypted value (password/key) stored in repo settings, injected at runtime, masked in logs. |
GITHUB_TOKEN | A temporary credential auto-issued per run to act on its own repo; expires when the run ends. |
permissions: | Declares how much the GITHUB_TOKEN can do — keep it minimal. |
| OIDC | OpenID Connect — lets a run prove its identity to get short-lived cloud access with no stored password. |
id-token: write | Permission to request an OIDC identity token. |
| Context | A data bag (github, secrets, steps, …) read via ${{ ... }}. |
| Expression | Logic inside ${{ ... }}, e.g. for if: conditions. |
| Cache | Saved folder restored across runs to skip slow work; never required for correctness. |
| Concurrency | Grouping/cancelling runs so they don’t trample each other. |
| Matrix | Running one job many times across a set of variables, in parallel. |
| Supply-chain risk | The danger of running third-party action code with your token; mitigated by SHA pinning. |
| Artifact | Files passed from one job to another (or to you) between separate runners. |
Previous: ← Anatomy
🎓 You finished the track. You can now read, write, debug, secure, and own a CI/CD pipeline — and you proved it by dissecting the one that ships this very site. That’s a genuine senior-engineer skill, and you have it.