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.

The lifecycle an expiry date creates An override is created with metadata and a linked design-system issue. As the expiry approaches, CI warns. At expiry, CI fails the pull request, forcing one of three outcomes: removal after the fix ships, promotion to a product component layer, or an explicit extension with justification. Without an enforced date, the entry becomes permanent. created metadata + DS issue warning window CI warns for 14 days expiry — CI fails the pull request blocks removed — DS fix shipped the intended outcome promoted to the product's own component layer extended, with a written justification in the diff Without an enforced date there is only one path: the entry stays, and the layer becomes a second design 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.

What the metric shows when governance works Two line trends over six months. The governed overrides layer stays between five and eight entries as new ones are added and old ones removed. The ungoverned layer grows steadily from five to over thirty entries because nothing is ever removed. Entries in the overrides layer, by month 0 18 36 Jan Mar May Jul enforced expiry — added and removed at similar rates comment-only promise — nothing is ever removed

Verification

  1. Add an override with a past expiry date on a scratch branch and confirm CI fails with the owner and ticket in the message.
  2. 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.
  3. Publish the count and median age:
Three outcomes an expiry forces Three possible resolutions when an override expires: removal because the design-system fix shipped, promotion into the product component band, or an explicit extension with a written justification. removed the DS fix shipped promoted it was a product variation extended with a justification in the diff All three are acceptable; silently ignoring the date is the only outcome the check exists to prevent.
grep -c "@override" src/styles/overrides.css
grep -o "expires: [0-9-]*" src/styles/overrides.css | sort | head -3
  1. 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.

Up: Override Layer Best PracticesArchitecture Patterns & Design System Scaling