Skip to content
Chapter 97Lesson 3

Signal checks and dependency hygiene

The non-blocking GitHub Actions checks and pnpm supply-chain defaults that keep a dependency tree current and safe.

The gate is done. Four jobs, typecheck, lint, test, and build, run on every pull request, and the branch-protection ruleset matches their names string-for-string, so a red check keeps the merge closed. That gate is deliberately small.

A pipeline can do much more. It can scan dependencies for known vulnerabilities, check the links in your README, lint the workflow files themselves, and let a bot open a pull request when a dependency falls behind. For each of these, this lesson asks: which get to block a merge, and which only inform you?

Draw that line wrong and a codebase decays quietly. By the end you’ll be able to place any new check on the right side of it, wire up the four supplementary checks, and close the dependency-hygiene loop: a dependency tree that stays current while updating it stays safe. After the Shai-Hulud attacks, that safety is no longer a given. You’ll touch four files: two more jobs in ci.yml, a scheduled links.yml, a dependabot.yml, and a few lines in pnpm-workspace.yaml.

Gate or signal: which checks block a merge

Section titled “Gate or signal: which checks block a merge”

A CI pipeline has two tiers that do two different jobs.

The gate is blocking. Every check in it sits in the required-status-check list, and every one answers a single question: does this merge break production? Type errors break production. A failing test means a behavior you rely on is gone. A build that won’t compile can’t ship. Four fast, blocking checks, and that is the whole gate.

Signal is everything else. A signal check runs and reports: it might comment on the pull request, open an issue, or just turn a job red in the Actions tab. What it never does is block the merge. It answers a broader, slower question than “is this merge safe?”: is the codebase healthy?

A false-positive gate is far more expensive than a false-positive signal. When a signal cries wolf, you ignore that one report and move on. When a gate cries wolf, going red on something that isn’t actually broken, it stands between a developer and a merge they know is fine. So they reach for the override: they click “merge anyway,” ask an admin for a branch-protection exception, or simply learn that red checks are sometimes safe to push past. Once a team has learned to bypass a red check, the instinct leaks onto the real gates, the ones that were telling the truth.

Think of it as a one-way ratchet. Promoting a signal into the gate later, once it has proven itself a reliable production predicate, is cheap: you add its name to a list. Walking back a bypass culture is expensive, because you are retraining a team’s reflexes, not editing a config file. So the default leans hard toward signal. A check earns its place in the gate; it is not granted one.

That gives you a test for any candidate check:

Does a failure here mean production is broken, or will be on merge? Yes → gate. No → signal.

Walk it through two cases. A tsc error violates the type contract the entire codebase is written against, and the broken code is about to merge: a gate, unambiguously. Now take a moderate CVE in a transitive build-time dependency with no exploitable path. It is real and worth triaging, but it is the wrong thing to block a Friday-afternoon hotfix on: a signal. The severity of a vulnerability and its right to block a merge are different axes, and conflating them is the mistake.

This lesson wires four signal checks:

  • pnpm audit: known vulnerabilities in your dependency tree.
  • A docs link-checker: rot in your README and docs.
  • actionlint: typos in the workflow files themselves.
  • Dependabot: keeping the dependency tree alive.

The walker below makes the decision explicit. Work through it for a check you are considering. It asks the questions in the order that matters: production impact first, feasibility second.

Gate or signal?

Production impact comes first because it is the expensive thing to get wrong. Feasibility comes second because a check that is right in principle but slow or flaky in practice trains the bypass habit just as surely as a check that is wrong. Anything you reach for the override on is a signal, whether you’ve admitted it yet or not.

A status check is the unit GitHub thinks in. Each job in your workflow reports one against the commit it ran on, and the ruleset’s required list is just a set of those names. “In the gate” and “in the required-status-check list” mean the same thing.

pnpm audit: vulnerabilities as a signal check

Section titled “pnpm audit: vulnerabilities as a signal check”

The first signal check is pnpm audit. It takes your installed dependency tree, checks it against the registry’s advisory database, and reports known vulnerabilities by severity (low, moderate, high, critical) along with the dependency paths that pull each one in.

In CI it is a job in ci.yml, sitting next to your four gate jobs but deliberately absent from the required-status-check list. This is the first concrete face of the gate-vs-signal split: same file, same runner, same setup, but the ruleset doesn’t name it, so it can go red without holding a merge. The command is pnpm audit --audit-level=high, which exits non-zero only on high and critical findings, so moderate-and-below noise doesn’t dominate the report.

The command is easy; the judgment lives in three tuning decisions.

The severity threshold. --audit-level=high is the starting floor, and the aim is to tune it so the signal stays read. An audit that goes red on every low-severity advisory buried three levels deep in your transitive dependencies is an audit nobody looks at, and a team that ignores the report is the exact failure that defeats the reason for having one.

Production scope versus everything. pnpm audit --prod scopes the report to production dependencies, dropping dev-only tooling such as test frameworks, build tools, and linters from the count. The trade is worth naming: a critical CVE in a dev dependency is real, but it rarely has a path to your running production app, because that code never ships to users. --prod is the right lens when the question is “what is exposed to the people using the product?”

Advisories you have triaged but can’t fix. Sometimes an advisory fires on a package you have already cleared, because there is no exploitable path or the upstream maintainer is slow to patch. Leave it flagged and it slowly drowns the rest of the report. The fix is a tool like IBM’s audit-ci, which lets you allowlist a specific advisory by ID with an expiry date, so the noise goes quiet without going permanently blind. Reach for it only once plain pnpm audit has built up a long tail of unfixed-but-harmless findings.

Here is the job. The setup is the same checkout-then-pnpm-then-node-then-install spine every job in this workflow uses, established by the four-job gate and explained in the earlier GHA primitives lesson. The only new lines are the audit command and the comment that pulls the job out of the gate.

.github/workflows/ci.yml
audit: # signal job — intentionally absent from the required-checks ruleset
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm audit --audit-level=high

One thing worth naming: the advisories pnpm audit reports almost always land on a transitive dependency , not on something in your own package.json. You install a dozen packages directly; they pull in hundreds more, and those are where most of the surface, and most of the risk, actually lives.

Supply-chain defenses: release age, provenance, and pinning

Section titled “Supply-chain defenses: release age, provenance, and pinning”

pnpm audit has a blind spot. It catches known vulnerabilities, flaws already discovered, written up, and added to the advisory database. It tells you nothing about the window before a compromise is known, and in 2026 that window is where the attacks live.

In September 2025, a self-propagating worm the security community named Shai-Hulud tore through npm. It stole maintainer credentials, used them to publish poisoned versions of those maintainers’ packages, and each poisoned package harvested the next set of credentials and kept spreading. It hit more than 500 packages, including widely used ones like @ctrl/tinycolor. The Mini Shai-Hulud waves followed in May 2026, ripping through the AntV charting ecosystem and packages like echarts-for-react; this time the payload dumped stolen CI/CD secrets into thousands of public repositories.

The threat here is not a dependency with a known bug; that is pnpm audit’s job, and it does it well. The threat is a legitimate, popular package whose latest version was published forty minutes ago by an account compromised an hour ago. The package is real, the maintainer is real, the version is poisoned, and your audit knows nothing because nobody has discovered it yet. This is a supply-chain attack , and the whole defense is about surviving the window between a version going bad and that fact becoming known. No single control closes it, so you stack several.

Layer 1, release age. minimumReleaseAge tells pnpm to refuse any package version until it is at least N minutes old. Most malicious versions are spotted and pulled within hours of publication, so the delay filters them automatically: by the time you would reach for the bad version, it has already been yanked , and you waited out its short, ugly lifespan. This setting lives in pnpm-workspace.yaml, not .npmrc; in current pnpm, .npmrc is for authentication and registry configuration only. pnpm 11 ships minimumReleaseAge on by default at 1440 (24 hours), so the move is not to add it from scratch but to verify it is on, and consider raising it to 4320 (72 hours) if your team can tolerate the lag. Push it too far, say two weeks, and you can no longer take a genuinely urgent same-day security patch. It is a value you tune, not a maximum you max out.

Layer 2, provenance and signatures. pnpm audit signatures verifies your installed packages against the registry’s signatures. Provenance goes further, attesting where and how a package was built under the SLSA framework. That is a stronger signal, but the May 2026 wave produced the first malicious npm packages carrying valid SLSA provenance. Provenance proves “this artifact was built by this pipeline from this source”; when the pipeline itself is compromised, that proof is true and useless at once. So treat provenance and signatures as one input, not a verdict, which is exactly why you also delay (Layer 1) and pin (Layer 3).

Layer 3, block exotic sub-dependencies and pin your actions. blockExoticSubdeps: true, also on by default in pnpm 11 and also in pnpm-workspace.yaml, refuses any sub-dependency that resolves from outside your configured registries, closing a quiet injection path where a transitive dependency points somewhere it shouldn’t. You met the parallel instinct one layer up: in the GHA primitives lesson you pinned GitHub Actions by commit SHA when they touch secrets, after the tj-actions/changed-files compromise. Same move, applied to CI instead of npm: don’t trust a moving tag for code that runs with your credentials. That left one question open, how do you keep those pins current instead of frozen and rotting, which the next section’s Dependabot answers by watching your uses: references and bumping them for you.

The file location is the single most likely place to go wrong, and the failure is silent. The tabs below show the same two settings in the wrong file and the right one.

.npmrc
minimumReleaseAge=4320
blockExoticSubdeps=true

Silently ineffective. pnpm never reads supply-chain settings from .npmrc, so the protection you think you switched on never engages, and nothing warns you.

actionlint: linting the workflows that run everything

Section titled “actionlint: linting the workflows that run everything”

There is a category of bug that is easy to forget exists: a bug in the workflow files themselves. Your ci.yml is code, and it can be wrong. Unlike your application code, its mistakes don’t surface when you commit them; they surface at run time, when the workflow fires.

A typo in a uses: reference, a ${{ }} expression that doesn’t exist, an unrecognized event trigger, a quoting bug in a run: block: every one sails past commit and fails only when GitHub tries to run the workflow. That is what makes it dangerous, because this is a bug in the thing that runs your gate. Bump pnpm/action-setup@v4 to a @v5 that doesn’t exist and it fails the setup, before any job can start, so the entire gate silently can’t execute. It is the gate’s gate, and nothing was checking it.

actionlint checks it. It is a static checker built for GitHub Actions, not a generic YAML linter that knows only indentation, so it understands the Actions schema: it type-checks your ${{ }} expressions, validates action inputs and runner labels against what actually exists, and runs shellcheck over your run: blocks. The whole class of run-time-only workflow bugs collapses into a lint that finishes in seconds.

You wire it as another job in ci.yml. Use the maintained wrapper around rhysd/actionlint so Dependabot’s github-actions stream keeps it current; a lint utility holding no secrets is fine pinned by tag.

.github/workflows/ci.yml
actionlint: # signal job — not in the required-checks ruleset
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: reviewdog/action-actionlint@v1

This job has no setup spine: actionlint needs neither pnpm nor Node, so it just checks out the repo and runs the linter.

The links in your README.md, your AGENTS.md, and your docs/** go stale. Domains move, repositories get renamed, pages 404. Any one dead link is low-stakes, but in aggregate they corrode: a README where every third link is broken reads as an abandoned project, and that impression does real damage to something very much alive.

So you want a link-checker, and the judgment to absorb is that per-PR is the wrong cadence for it. A docs PR would re-check the whole tree and flag links it never touched; a hotfix shouldn’t wait on your README’s external links resolving before it can merge.

The right shape is a scheduled sweep. Once a week, say Monday morning, a workflow checks every Markdown file in the repo and, on breakage, opens a GitHub issue. It runs on its own clock, batches all the breakage into one place, and never sits in anyone’s pull request. This is the gate-vs-signal split taken one step further than the audit job: the check doesn’t merely not block the merge, it doesn’t even run on the PR.

That introduces the on: schedule trigger, which carries one gotcha that catches everyone exactly once: a scheduled workflow always runs as it exists on the default branch. Change a scheduled workflow on a feature branch, then sit waiting for the cron to prove your change works, and you will wait forever, because the cron runs the main version, not yours. The fix is a reflex worth building now: add a workflow_dispatch trigger to the same workflow. That gives you a manual “Run workflow” button to fire a one-off run on demand and actually test it.

Because this check answers to a different trigger than the PR gate, it earns its own file, links.yml, rather than folding into ci.yml. The job runs a Markdown link-checker over **/*.md and, on failure, opens an issue through a maintained create-issue action. It earns its weight once the docs surface is more than a handful of files; for a single-README repo, skip it.

.github/workflows/links.yml
name: links
on:
schedule:
- cron: '0 9 * * 1' # Mondays, 09:00 UTC
workflow_dispatch: {}
permissions:
contents: read
issues: write
jobs:
link-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: lycheeverse/lychee-action@v2
- if: failure()
uses: peter-evans/create-issue-from-file@v5
with:
title: Broken links found in docs
content-filepath: ./lychee/out.md

The permissions: block grants issues: write because opening an issue is a write the default read-only floor forbids, so the workflow raises the floor for exactly that one scope: the least-privilege pattern from the GHA primitives lesson.

Keeping dependencies current with Dependabot

Section titled “Keeping dependencies current with Dependabot”

The last signal check is less a check than an engine: it keeps your dependency tree current and ties together every thread in this lesson.

Dependencies drift out of date the moment you stop watching them. Security patches go untaken, and a major version you keep deferring grows harder to adopt every month. The naive fix, a quarterly pnpm update that bumps everything at once and hopes for the best, is exactly the kind of big-bang change this course teaches you to avoid. The fix is a loop that keeps turning: updates arrive on a cadence, grouped sensibly, each one running the full gate, the low-risk ones merging themselves and the risky ones landing in front of a human. Dependabot is GitHub’s native engine for that loop.

Turn on three update streams. npm watches your JavaScript dependency tree, the packages in package.json. github-actions answers the question the GHA primitives lesson left open: you pinned your actions by SHA for safety, so how do you keep those pins current instead of frozen and rotting? This stream watches the uses: references in your workflow files and opens pull requests to bump them as new versions ship. The pin gives you safety, the stream gives you freshness. docker is the third, worth adding only if your repository has a Dockerfile.

The decision that makes Dependabot useful rather than unbearable is grouping. The old default opened one pull request per dependency, which on a real tree means fifty PRs a week, and a team buried under fifty dependency PRs rationally stops looking at them. That is the noisy-audit failure mode again: an ignored signal costs attention and returns nothing. A groups: block per ecosystem collapses every minor and patch update into one pull request per ecosystem on a weekly cadence, while major bumps stay separate.

That split tracks semver ’s risk gradient. Patch and minor releases promise backward compatibility, so batching a dozen into one PR that runs the full gate is low-risk by construction. A major release announces breaking changes, a changed API or a dropped feature, so it earns its own PR and its own human read.

version: 2
updates:
- package-ecosystem: 'npm'
directory: '/'
schedule:
interval: 'weekly'
groups:
npm-minor-patch:
applies-to: version-updates
patterns:
- '*'
update-types:
- 'minor'
- 'patch'
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: 'weekly'
groups:
actions-minor-patch:
applies-to: version-updates
patterns:
- '*'
update-types:
- 'minor'
- 'patch'

version: 2 is the current schema. updates: is a list with one entry per ecosystem you want Dependabot to watch.

version: 2
updates:
- package-ecosystem: 'npm'
directory: '/'
schedule:
interval: 'weekly'
groups:
npm-minor-patch:
applies-to: version-updates
patterns:
- '*'
update-types:
- 'minor'
- 'patch'
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: 'weekly'
groups:
actions-minor-patch:
applies-to: version-updates
patterns:
- '*'
update-types:
- 'minor'
- 'patch'

The npm entry, your JavaScript dependency tree. directory: '/' points at the repo root where package.json lives; the schedule sets a weekly cadence.

version: 2
updates:
- package-ecosystem: 'npm'
directory: '/'
schedule:
interval: 'weekly'
groups:
npm-minor-patch:
applies-to: version-updates
patterns:
- '*'
update-types:
- 'minor'
- 'patch'
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: 'weekly'
groups:
actions-minor-patch:
applies-to: version-updates
patterns:
- '*'
update-types:
- 'minor'
- 'patch'

The focal move. update-types: ['minor', 'patch'] with patterns: ['*'] collapses every backward-compatible bump into one weekly PR. Majors fall out of the group and get their own PRs.

version: 2
updates:
- package-ecosystem: 'npm'
directory: '/'
schedule:
interval: 'weekly'
groups:
npm-minor-patch:
applies-to: version-updates
patterns:
- '*'
update-types:
- 'minor'
- 'patch'
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: 'weekly'
groups:
actions-minor-patch:
applies-to: version-updates
patterns:
- '*'
update-types:
- 'minor'
- 'patch'

The github-actions entry repeats the shape and pays off the SHA-pinning debt from the GHA primitives lesson: it watches your uses: references and keeps the pinned actions current.

1 / 1

One move closes the loop: auto-merge, scoped to the lowest risk. Auto-merge pairs naturally with Dependabot, and its mechanics, the gh command and the branch-protection merge queue, you set up in the Git chapter. A patch-update PR opens, runs the full four-job gate, and merges itself the moment everything is green.

The boundary is the senior part: auto-merge patch updates only; require a human for minor and major. A patch release says it is just a bug fix, but a behavior change can hide in one, so the gate runs on Dependabot’s pull requests too. The four-job gate you built earlier is precisely what makes it safe to let dependency updates merge themselves unattended. Hygiene and the gate are two halves of one loop: keep the tree current, keep every merge safe.

Renovate is the more configurable alternative: multi-platform, with regex managers that pin versions in arbitrary files, shared config presets across an organization, and stability windows on auto-merge. Reach for it when Dependabot’s ceiling binds, such as merging only after a maturity window or sharing one config across many repositories. For a single web-app repo, Dependabot is simpler and built in.

Four signal checks now live across a few files, and one rule decides which file each lands in.

Three files hold the steady state. The signals that share the pull-request trigger, audit and actionlint, fold into ci.yml as non-required jobs beside the gate. The link-check rides its own links.yml because it runs on a schedule, not a PR. And dependabot.yml is configuration, not a workflow: it tells Dependabot how to behave rather than defining jobs.

Split workflows when their triggers differ; fold them when one trigger covers all.

pnpm-workspace.yaml sits outside that rule. It is not CI but dependency-resolution policy: the release-age and exotic-subdep floors from earlier.

The two tiers are the model to keep. The gate is small, fast, and blocking, because every member is a true production predicate and a false positive trains a bypass habit you can’t undo. Signal is broad, advisory, and on its own cadence, because its job is health, not safety.

The discipline that protects both is one refusal: never drag a noisy signal into the required list. Promote a signal to the gate only once it has earned it, as a proven production predicate that is fast and deterministic. Earned, not granted.

Sort each check into its tier. This is the call you’ll make on every check a SaaS repo adds.

Sort each check into the tier it belongs in on a 2026 SaaS repo. Drag each item into the bucket it belongs to, then press Check.

Gate (blocks merge) A true production predicate, fast and deterministic
Signal (runs, never blocks) Informs the team; never holds a merge
pnpm typecheck
pnpm lint
pnpm test
pnpm build
pnpm audit --audit-level=high
actionlint
The weekly link-check
A Dependabot version-update PR

The dependabot.yml config carries a few load-bearing values worth reproducing from memory. Fill in the blanks.

Reconstruct the load-bearing values in this Dependabot config. Pick the right option from each dropdown, then press Check.

.github/dependabot.yml
version: 2
updates:
- package-ecosystem: ___
directory: '/'
schedule:
interval: ___
groups:
npm-minor-patch:
applies-to: version-updates
patterns:
- '*'
update-types: ___

The supply-chain and Dependabot configuration here moves fast: versions, defaults, and key names shift between releases. These are the canonical references to check against when you wire this into a real repo.