Sunsetting Overrides With a Deprecation Workflow
An overrides layer starts as a pressure valve and becomes a second design system. The mechanism is always the same: an entry is added under deadline pressure with a comment promising to remove it, the comment is never read again, and two years later nobody can delete anything because nobody knows what still depends on it. The fix is to make the promise machine-readable. This extends override layer best practices in the architecture patterns reference.
Prerequisites
An overrides layer at the top of the manifest, and a CI job that can run a Node script over the CSS sources.
/* WHY overrides is last: it must be able to beat everything, which is what
makes it dangerous and why it needs a governance process rather than a
convention. */
@layer reset, vendor, base, theme, components, utilities, overrides;Step 1: Require a structured header on every entry
@layer overrides {
/* @override
owner: checkout
ticket: DS-4412
expires: 2026-09-30
reason: design system ships insufficient contrast on .btn--ghost:disabled
*/
.checkout-panel .btn--ghost:disabled {
color: var(--color-text-muted);
}
}What this does: turns a comment into data. The four fields are chosen so a reviewer can answer the only questions that matter — who owns this, what will replace it, when does it go, and why does it exist — without opening a ticket system.
Step 2: Fail CI on overdue or unlabelled entries
// scripts/check-overrides.mjs
// WHY parse the CSS rather than a separate registry: a registry drifts from
// the code, and the whole point is that the expiry travels with the rule it
// applies to.
import postcss from 'postcss';
import { readFile } from 'node:fs/promises';
// WHY the date comes from an argument, not from the clock inside the parser:
// it keeps the script deterministic and testable.
const today = process.env.CHECK_DATE ?? new Date().toISOString().slice(0, 10);
const WARN_DAYS = 14;
const HEADER = /@override\s+([\s\S]*?)\*\//;
const FIELD = /^\s*(owner|ticket|expires|reason):\s*(.+?)\s*$/gm;
const css = await readFile('src/styles/overrides.css', 'utf8');
const root = postcss.parse(css, { from: 'src/styles/overrides.css' });
const problems = [];
root.walkRules((rule) => {
const prev = rule.prev();
const raw = prev?.type === 'comment' ? `${prev.text}*/` : '';
const match = HEADER.exec(raw);
if (!match) {
problems.push({ level: 'error', line: rule.source.start.line,
msg: `"${rule.selector}" has no @override header` });
return;
}
const meta = Object.fromEntries(
[...match[1].matchAll(FIELD)].map((m) => [m[1], m[2]])
);
for (const field of ['owner', 'ticket', 'expires', 'reason']) {
if (!meta[field]) {
problems.push({ level: 'error', line: rule.source.start.line,
msg: `"${rule.selector}" is missing "${field}"` });
}
}
if (!meta.expires) return;
const days = Math.round(
(Date.parse(meta.expires) - Date.parse(today)) / 86_400_000
);
if (days < 0) {
problems.push({ level: 'error', line: rule.source.start.line,
msg: `"${rule.selector}" expired ${-days}d ago (${meta.ticket}, owner ${meta.owner})` });
} else if (days <= WARN_DAYS) {
problems.push({ level: 'warn', line: rule.source.start.line,
msg: `"${rule.selector}" expires in ${days}d (${meta.ticket})` });
}
});
for (const p of problems) console[p.level === 'error' ? 'error' : 'warn'](
`${p.level.toUpperCase()} overrides.css:${p.line} — ${p.msg}`
);
process.exit(problems.some((p) => p.level === 'error') ? 1 : 0);What this does: makes the expiry real. Note the warning window — an entry that fails on the day it expires, with no notice, gets extended rather than fixed.
Step 3: Route the override to a fix when it is written
# .github/workflows/override-added.yml
# WHY at creation time: the moment the override is written is the only moment
# anyone fully understands the problem. Opening the design-system issue two
# months later means reconstructing it from a CSS selector.
on:
pull_request:
paths: ['src/styles/overrides.css']
jobs:
remind:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: node scripts/check-overrides.mjs
- name: Comment on the PR
run: |
gh pr comment "$PR" --body "This adds an override. Confirm the linked
design-system ticket exists and is scheduled before the expiry date."
env:
PR: $
GH_TOKEN: $What this does: converts each override into a tracked design-system defect rather than a private patch. That link is what makes removal possible later.
Step 4: Verify before removing
/* WHY verify rather than delete-and-hope: the DS fix may address the same
symptom differently, and the override may have been compensating for a
second, undiagnosed issue. */
/* 1. Delete the override block.
2. Render the affected component in every state it supports.
3. Compare against the pre-fix screenshots stored with the ticket.
4. If they differ, the DS fix is incomplete — reopen it rather than
restoring the override with a new expiry date. */What this does: stops the most common failure of a deprecation workflow, which is an override being “renewed” indefinitely because nobody checked whether it was still needed.
Verification
- Add an override with a past expiry date on a scratch branch and confirm CI fails with the owner and ticket in the message.
- Add one with no header and confirm it fails differently — a missing header and an overdue entry are different problems and should read that way.
- Publish the count and median age:
grep -c "@override" src/styles/overrides.css
grep -o "expires: [0-9-]*" src/styles/overrides.css | sort | head -3- Confirm every entry’s ticket resolves to a real, open or closed issue. A dangling reference means the routing step was skipped.
Troubleshooting
The check fails on the day of a release freeze. : That is the check working, but the timing is hostile. Set the warning window wide enough that the failure never arrives unannounced, and allow an extension in the same pull request with a justification line.
Everyone extends instead of fixing. : The design-system tickets are not being prioritised, which is a scheduling problem the CSS cannot solve. Report the extension count alongside the entry count — a rising extension rate is the signal that needs escalating.
An override has no clear owner because the team dissolved. : Reassign it to whoever owns the affected surface today, or delete it and see what breaks in staging. An unowned override is unremovable by definition, which is the worst state.
The parser misses overrides written with a different comment style. : Standardise on one header format and lint for it. A second format means a second parser, and the entries in it are effectively invisible.
Removing an override reintroduced the original bug. : The design-system fix did not cover the case. Reopen that ticket rather than restoring the override with a fresh date — a renewed expiry with no new information is how entries become permanent.
Complete working example
/* ============================================================
src/styles/overrides.css — every entry, one format
============================================================ */
@layer overrides {
/* @override
owner: checkout
ticket: DS-4412
expires: 2026-09-30
reason: design system ships 2.9:1 contrast on .btn--ghost:disabled;
fails WCAG AA. Remove when DS-4412 lands in @org/ds 8.2.
*/
.checkout-panel .btn--ghost:disabled {
color: var(--color-text-muted);
}
/* @override
owner: search
ticket: DS-4517
expires: 2026-08-15
reason: the results grid needs a 3-column breakpoint the DS Grid does
not expose yet. DS-4517 adds a `columns` prop.
*/
.search-results .ds-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
/* @override
owner: platform
ticket: VEN-88
expires: 2026-12-01
reason: @vendor/maps 3.x sets a z-index of 9999 on its controls, which
covers the app header. Upstream fix tracked in VEN-88; remove
when we upgrade to 4.x.
*/
.map-container .leaflet-top {
z-index: 40;
}
}{
"scripts": {
"//": "WHY a separate script rather than a Stylelint rule: the check needs date arithmetic and a readable failure message naming the owner, which a lint rule cannot express well.",
"css:overrides": "node scripts/check-overrides.mjs",
"ci": "npm run css:overrides && npm run lint && npm run build"
}
}Frequently Asked Questions
How long should an override be allowed to live?
One design-system release cycle is a reasonable default — long enough for a real fix to be scheduled and shipped, short enough that the people who wrote the override still remember why. The specific number matters far less than the enforcement: an expiry date that nothing checks is a comment, and comments in an overrides layer have a perfect record of being ignored.
What if the design system will not fix the underlying issue?
Then it was never an override. An override is a temporary patch for a defect that someone else has agreed to fix; a variation the design system has deliberately declined to support is a legitimate product-level style, and it belongs in that product’s own component layer with a normal owner and no expiry. Moving it out is the right resolution — it shrinks the overrides layer and puts the rule somewhere it can be maintained.
Should the CI check block a deploy or only warn?
Block the pull request. Failing a merge puts the decision in front of a person who is already looking at the code and can choose to fix, extend with a written reason, or delete. Failing a deploy instead punishes whoever happens to ship next — someone with no context on the override — and the reliable outcome of that is a team that learns to skip the check.
Related
- Override Layer Best Practices — what belongs in the layer in the first place
- Auditing the Overrides Layer in CI — the static checks this workflow builds on
- Preventing Style Collisions in Large Frontend Teams — the pressure that fills the layer
- Layer Naming Conventions and Governance — the ownership model each entry references
Up: Override Layer Best Practices → Architecture Patterns & Design System Scaling