GitHub Actions — Pinning to @main Breaks CI Silently
CI breaks at 2:17 AM: 'Input required: ref'—no code push.
- Workflow: YAML file triggered by an event — one event can trigger many workflows
- Job: parallel unit of work — runs simultaneously by default, use 'needs' for sequencing
- Step: sequential within a job — either a shell command (run) or pre-built Action (uses)
- Secrets: scoped by level — org, repo, or environment (most secure)
- Concurrency: prevents deployment race conditions with cancel-in-progress
Imagine you run a bakery. Every time a new recipe is approved, you want your staff to automatically test it, bake a sample, taste it, and ship it to stores — without you lifting a finger. GitHub Actions is that automated staff for your code. Every time you push code, it kicks off a chain of tasks: run tests, build the app, deploy to a server. You write the instructions once, and it just happens every single time.
GitHub Actions is a CI/CD platform that runs workflows defined as YAML files in your repository. Workflows are triggered by events (push, pull request, schedule, manual) and execute jobs that contain sequential steps. The platform provides hosted runners, a marketplace of 16,000+ pre-built Actions, and built-in secrets management.
The key architectural decisions: jobs run in parallel by default (use needs for sequencing), steps within a job share a filesystem (install in step 1, use in step 2), and runners are ephemeral (clean every run unless you explicitly cache or upload artifacts). Secrets are scoped at three levels — org, repo, and environment — with environment secrets being the most secure for production credentials.
Common misconceptions: that secrets are automatically redacted in all contexts (they are not — encoding/decoding bypasses redaction), that on: push and on: pull_request have the same permissions (fork PRs get read-only access and no secrets), and that pinning Actions to branch tags is safe (it is not — a maintainer's breaking change breaks your pipeline without any code change from you).
How GitHub Actions Actually Works: Events, Workflows, Jobs, and Steps
The mental model is a clean hierarchy, and getting it right changes everything. At the top is a Workflow — a YAML file in .github/workflows/. A workflow is triggered by an Event: a push, a pull request, a schedule, or even a manual button click in the GitHub UI. One event can trigger many workflows.
Inside a workflow are Jobs. Jobs are the parallel units of work. By default they run simultaneously — so your 'run tests' job and your 'lint code' job can race each other. That's a huge speed win. If you need sequencing (don't deploy until tests pass), you declare explicit dependencies with needs.
Inside each job are Steps. Steps are sequential within a job — they share the same runner machine and filesystem, which is why you can install Node in step 1 and use it in step 2. Each step is either a shell command (run) or a pre-built Action (uses). Those pre-built Actions are the real superpower: the community has published Actions for deploying to AWS, sending Slack messages, caching npm dependencies — thousands of them on the GitHub Marketplace.
The runner is just a virtual machine spun up on demand by GitHub. It's clean every run — nothing carries over between workflow runs unless you explicitly cache it or upload an artifact.
- npm ci reads package-lock.json exactly — no resolution, no surprises
- npm install resolves dependencies fresh — lock file may change
- CI should test the exact tree your teammates agreed on, not a fresh resolution
- npm ci also deletes node_modules first for a clean install — stricter by design
Handling Secrets, Environment Variables, and Multi-Environment Deployments
Here's where most tutorials fail you: they show you how to reference a secret but not how to think about secrets architecture for a real project. Let's fix that.
GitHub has three levels of secrets: Organization secrets (shared across repos), Repository secrets (just this repo), and Environment secrets (scoped to a named deployment environment like 'staging' or 'production'). Environment secrets are the most powerful for CI/CD because GitHub won't hand them to a workflow unless it's deploying to that specific named environment — and you can add required reviewers, meaning a human must approve before prod secrets are ever exposed.
The environment key on a job is what unlocks this. When you add environment: production to a deployment job, GitHub checks if that environment exists, applies its protection rules (required reviewers, wait timers), and only then injects its secrets into the job's environment variables.
Never log secrets. GitHub automatically redacts known secret values from logs, but if you base64-encode a secret and then decode it in a run step and echo it, GitHub has no idea that string is sensitive. The redaction is string-match based, not magic.
- Repository secrets: accessible to every job in every workflow — including fork PRs
- Environment secrets: only accessible to jobs with the matching environment key
- Environment secrets support required reviewers — human approval before prod secrets are exposed
- Use environment secrets for production credentials. Use repo secrets only for non-sensitive config.
Caching, Build Matrices, and Reusable Workflows — Scaling Without Pain
Once your pipeline works, the next battle is speed and maintainability. Three features change the game at scale.
Caching is the fastest win. Without it, npm ci downloads every package fresh on every run. With actions/cache (or the built-in cache on actions/setup-node), the node_modules are restored from a cache key built from your package-lock.json hash. If the lock file hasn't changed, you skip the download entirely. Same principle works for pip, Maven, Gradle, and Cargo.
Build matrices let you run the same job across multiple configurations in parallel without duplicating YAML. Testing against Node 18 and 20? Two browsers? Three operating systems? A matrix expands one job definition into N parallel jobs automatically. Failed combinations are clearly labeled, passing ones don't block each other.
Reusable workflows solve the DRY problem at the organization level. Instead of copy-pasting a 'deploy via SSH' job across 12 microservice repos, you define it once in a central repo and call it with uses: your-org/devops-workflows/.github/workflows/ssh-deploy.yml@main. Update the template once, every repo benefits. This is the pattern that separates organizations that maintain CI/CD well from those that have 12 slightly-different-and-all-broken pipelines.
- strategy.matrix expands one job definition into N parallel jobs
- fail-fast: true (default) cancels all jobs when one fails — you lose visibility
- fail-fast: false lets all jobs complete — see which configs are broken
- Use fail-fast: true for CI speed. Use fail-fast: false for debugging.
Branch-Pinned Action Updated Upstream: CI Pipeline Broken for 14 Hours
uses: actions/checkout@main — pinned to the main branch of the checkout Action.
2. The checkout Action maintainer pushed a commit that renamed the ref input to repository-ref.
3. The team's workflow still passed ref: ${{ github.sha }} which no longer existed as an input.
4. The checkout Action failed with 'Input required and not supplied: ref' because the input was renamed.
5. The team's workflow YAML had not changed — the upstream Action changed under them.
6. The scheduled nightly build picked up the new Action version automatically.
7. The team spent 14 hours debugging before checking the Action's changelog.uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683.
2. Update the input name from ref to repository-ref to match the new Action version.
3. Team rule: all third-party Actions must be pinned to commit SHAs, not branch tags or floating version tags.
4. Added a linting step that checks workflow YAML for non-SHA-pinned Actions: uses: zricethezav/actionlint@latest.
5. Set up Dependabot alerts for Action version updates so the team can review and update SHA pins deliberately.- Pin third-party Actions to commit SHAs, not branch tags. @main means 'whatever is on main right now' — it changes without your consent.
- A breaking change in a pinned Action breaks your pipeline with no code change from you. The failure looks like a platform issue, not an Action issue.
- Use actionlint or similar tools to enforce SHA pinning across all workflow files.
- Set up Dependabot alerts for Action updates so you can review changes before updating your SHA pin.
concurrency: { group: deploy-${{ github.ref }}, cancel-in-progress: true }.
4. Prevention: all deployment workflows must have concurrency groups.key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}.
2. If package-lock.json changes on every run (e.g., version bumping scripts), the cache key changes every time.
3. Check: git diff HEAD~1 package-lock.json — is the lock file changing when it should not?
4. Fix: use restore-keys as a fallback: ${{ runner.os }}-node- to get partial cache hits.on: pull_request_target for fork PR workflows that need secrets — but read the security implications first.
3. Better: use OIDC for cloud credentials (short-lived tokens, no stored secrets).
4. Alternative: skip integration tests on fork PRs, run them after merge.Key takeaways
concurrency key with `cancel-in-progressInterview Questions on This Topic
Frequently Asked Questions
That's CI/CD. Mark it forged?
3 min read · try the examples if you haven't