Career OS

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:

  1. Use secrets and understand the auto-provided GITHUB_TOKEN.
  2. Apply least-privilege permissions and explain why the default is dangerous.
  3. Explain OIDC (id-token: write) — passwordless cloud auth — at a high level.
  4. Read contexts and expressions (${{ ... }}) and the if: conditional.
  5. Speed up pipelines with caching and protect them with concurrency.
  6. Recognize supply-chain risk and pin third-party actions to a commit SHA.
  7. Dissect this site’s deploy.yml completely 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 read contents 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 — the deploy job 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 }}"
ContextHoldsExample
githubInfo about the event/repogithub.sha, github.ref_name, github.actor
secretsYour stored secretssecrets.MY_API_KEY
envEnvironment variables you setenv.NODE_ENV
stepsOutputs from earlier stepssteps.deployment.outputs.page_url
runnerInfo about the machinerunner.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) → @v4 tags 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-privilege permissions, this is how you stay safe using the Marketplace.

9. Debugging a red pipeline

When a run fails:

  1. Read the failing step — it’s highlighted ❌, and the error is usually the last lines of its log.
  2. Re-run — “Re-run failed jobs” retries without redoing the green ones. “Re-run with debug logging” adds verbose output.
  3. 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.
  4. 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:

  1. Why does deploy have needs: build? Because they run on separate machines; deploy must wait for build to finish and it relies on the artifact build uploaded. Remove needs: and deploy races build — publishing nothing or stale files.
  2. Why upload-pages-artifact in build and nothing obvious downloading it in deploy? actions/deploy-pages@v4 downloads the Pages artifact automatically — that’s the cross-job handoff (concept ⑥/⑧ from Lecture 2).
  3. What does removing id-token: write break? 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.)
  4. What does cancel-in-progress: true protect 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_request trigger to the build job only (test PRs without deploying them), or add a link-checker step before Build website. That’s the difference between copying a workflow and owning one.

⚠️ Common misconceptions

Warning

  • “The default GITHUB_TOKEN is harmless.” It can be powerful. Always scope permissions: down.
  • “Pinning to @v4 is fully safe for any action.” Safe-ish for official actions/*. 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 echo one 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

Why AI can’t do this for you

AI will happily add permissions: write-all to 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

TermMeaning
SecretAn encrypted value (password/key) stored in repo settings, injected at runtime, masked in logs.
GITHUB_TOKENA 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.
OIDCOpenID Connect — lets a run prove its identity to get short-lived cloud access with no stored password.
id-token: writePermission to request an OIDC identity token.
ContextA data bag (github, secrets, steps, …) read via ${{ ... }}.
ExpressionLogic inside ${{ ... }}, e.g. for if: conditions.
CacheSaved folder restored across runs to skip slow work; never required for correctness.
ConcurrencyGrouping/cancelling runs so they don’t trample each other.
MatrixRunning one job many times across a set of variables, in parallel.
Supply-chain riskThe danger of running third-party action code with your token; mitigated by SHA pinning.
ArtifactFiles 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.

Saves your progress on this device.