Sunset Banners Beat Surprise Breakage: A 90-Day Deprecation Contract for Internal CLI Commands

The failure mode is not the removal. It is the silence before it.

A command disappears. The release note says “removed legacy flag” in a bullet nobody read. The first real signal is a red pipeline in a repository whose owner has never met the person who removed it. In a 20–200 engineer company, that is not an edge case. It is the default outcome of treating internal tooling as a favor instead of a product.

The fix is not more communication. It is a contract with checkpoints that are observable in the tool itself. This article lays out a 90-day deprecation contract for internal CLI commands: three checkpoints, a short written record, and an audit that produces evidence instead of apologies. The 90 days is a chosen default, not an industry standard. The sources below show published windows ranging from 30 days to 24 months depending on context. That spread is the argument for writing your own number down and owning it.

Separate the two events your contract must distinguish

Deprecation and sunset are different events, and conflating them is where most internal tooling announcements go wrong. RFC 8594 describes deprecation as happening in two stages: first the API is no longer the preferred version but remains operational, and only the second stage — decommissioning — is what the Sunset header represents. The RFC is explicit that the Sunset header is not appropriate for the first stage because the API “remains operational and can still be used” (RFC 8594, §1.4).

The Deprecation header draft makes the same point from the other direction: “The act of deprecation does not change any behavior of the resource” (draft-ietf-httpapi-deprecation-header-09, §5). A deprecated command still works. It is no longer recommended. That distinction is the whole contract.

For a CLI command, the mapping is direct even though the RFCs describe HTTP headers, not terminal output:

  • Deprecation — the command runs, produces its normal output, and prints a banner naming the sunset date and the replacement. Behavior is unchanged.
  • Sunset — the command exits non-zero with a message naming the date and the replacement. Behavior has changed, and the date was published in advance.

The draft also requires that the Sunset timestamp not be earlier than the Deprecation timestamp (§4). In CLI terms: the banner must appear before the command stops working, and the gap must be long enough that the banner is not the first time anyone hears about it.

Checkpoint 1 — Announce (day 0): put the date in the command’s own output

The announcement is not a wiki page. It is a line the command prints every time it runs, starting on day 0. The format matters less than the contents. A workable banner:

warning: `chg deploy --legacy` is deprecated and will be removed on 2026-01-15.
  replacement: `chg deploy --strategy=rolling`
  migration: https://internal.example/docs/deploy-migration
  owner: platform-tooling (slack: #platform-tooling)

Three design choices are worth defending:

Write to stderr, not stdout. Stdout is the command’s data channel. Scripts parse it. If the banner goes to stdout, every pipeline that consumes the command’s output breaks on day 0 — the contract causes the breakage it exists to prevent. Stderr is where diagnostics belong, and it is where a warning belongs. This is a design recommendation, not a claim about any specific tool’s behavior.

Name the replacement and the migration doc. A banner that says “deprecated” without saying “use this instead” converts a removal into a research project. The replacement command and a URL are the minimum.

Name the owner. The banner is the only place a colleague is guaranteed to see who to ask. If the owner is a team channel, say so. If it is a person, say so. An unowned deprecation is a rumor.

The principle behind notifying on every invocation is not novel. Google’s deprecation guidance for Conversational Actions recommended notifying users whenever they invoke the action and using that opportunity to point to migration paths (Google Developers, Deprecation best practices). The same guidance recommended starting notifications at least 30 days before the deprecation date. That is a floor for a consumer product with a captive audience. Internal tooling has a smaller audience and a longer tail of forgotten scripts, which is why the default here is longer.

Checkpoint 2 — Warn (mid-window): make the warning machine-detectable

A banner is a signal to a human reading a terminal. Most invocations of an internal CLI happen inside CI, where no human reads the terminal. The warning has to be detectable by the build system, or the contract only reaches the people who already knew.

Two mechanisms, used together:

  1. A structured line on stderr. A single line with a stable prefix that CI can grep, for example DEPRECATION: command=deploy-legacy sunset=2026-01-15 replacement=deploy-rolling. The human-readable banner can stay; the structured line is what a policy check reads.
  2. A build-policy check. A CI step that fails when the structured line appears in a job’s output, with an allowlist for repositories that have a recorded exception. This turns migration from a communication problem into a build-policy problem: remaining callers surface on every pipeline run instead of at the deadline.

The mid-window checkpoint is also where the owner starts reporting. Once a week, the owner publishes the list of repositories and jobs still invoking the command, with counts. The banner is the signal; the report is what drives migration. A deprecation with a banner and no report is a hope.

This is the CLI analogue of the API practice of logging every call to a deprecated endpoint and identifying affected accounts (Mintlify, API deprecation guide). The API version uses API keys; the CLI version uses repository and job identity from CI metadata. The principle is the same: you cannot migrate callers you cannot name.

Checkpoint 3 — Enforce (day 90): fail loudly, not silently

On the sunset date, the command exits non-zero. The message names the date, the replacement, and the migration doc. It does not pretend the command never existed, and it does not silently fall through to a different behavior.

A hard failure with a clear message beats silent removal for one reason: it is diagnosable. A command that vanishes produces “command not found” and a search through release notes. A command that fails with a message produces the replacement command and a URL in the same terminal. The failure is the last notification, and it should be the most informative one.

The sunset date must be fixed once announced. Zuplo’s guide on sunsetting APIs states that once announced, the final shutdown date should remain fixed to maintain user trust and ensure effective planning (Zuplo, How to sunset an API). The same logic applies internally, with one addition: an extension is allowed, but only through a written exception that records who is still calling the command and why. An extension granted verbally is a date that was never real.

RFC 8594 is careful to describe Sunset timestamps as hints rather than guarantees (§3). That caution is about clients that cannot be reached. An internal CLI has a captive audience and a named owner; the date can be a commitment, and the exception process is what keeps it honest.

The contract document: short enough to paste into a design doc

The contract is a record, not a policy manual. If it does not fit on one screen, it will not be read, and an unread contract is the same as no contract. The fields:

Command:        chg deploy --legacy
Owner:          platform-tooling (#platform-tooling)
Announced:      2025-10-17
Sunset:         2026-01-15
Replacement:    chg deploy --strategy=rolling
Migration doc:  https://internal.example/docs/deploy-migration
Behavior at sunset: exit code 2, message names date and replacement
Exception process: written request to owner; records caller, reason, new date
Audit:          weekly caller report to owner; CI policy check on structured line

Two fields carry most of the weight. Owner is a named team or person, not a committee. Exception process is written, because the exception is where contracts quietly die. If an extension can be granted in a chat message, the sunset date is decorative.

The changelog entry is part of the record, not a substitute for it. Keep a Changelog defines a “Deprecated” category for soon-to-be-removed features and recommends listing deprecations, removals, and breaking changes (Keep a Changelog 1.1.0). The changelog tells readers what changed. The contract tells the owner what they owe and by when.

Versioning: what SemVer does and does not settle for internal CLIs

Semantic Versioning requires that marking public API functionality as deprecated increments the minor version, and that at least one minor release containing the deprecation should precede removal in a major release (SemVer 2.0.0). That is a useful default for a CLI that ships as a versioned artifact: the deprecation lands in a minor release, the removal lands in a major release, and the version number itself carries the signal.

But SemVer does not specify a calendar. A minor release can follow a major release by a week or a year. The 90-day contract supplies what SemVer leaves open: a date, an owner, and an audit. Use both. The version number tells consumers what kind of change to expect; the contract tells them when.

One caution: SemVer’s rules apply to a declared public API. An internal CLI’s “public API” is whatever its callers depend on — flags, output formats, exit codes. If that surface is not written down, the version number is not communicating anything precise. The contract’s replacement field is a small step toward declaring that surface.

Audit: find every caller before the deadline, not after

The audit has two halves, and both are reproducible.

Static search. Grep the monorepo for the command string across CI configs, shell scripts, Makefiles, Dockerfiles, documentation, and onboarding guides. This catches the callers that are written down. It misses the ones constructed at runtime, which is why the second half exists.

Runtime observation. The structured warning line from checkpoint 2 is the runtime signal. A CI policy check that fails on the line, with an allowlist for recorded exceptions, produces a list of remaining callers on every pipeline run. The weekly report is that list, deduplicated by repository and job.

The two halves answer different questions. Static search answers “where might this be called?” Runtime observation answers “where is this actually called?” The gap between them is where surprise breakage lives: a script that builds the command from variables will not appear in a grep, but it will appear in the warning line.

This is the internal-tooling version of the API practice of logging calls to a deprecated endpoint and contacting the largest affected accounts directly (Mintlify). The CLI version contacts repository owners, not accounts, but the sequencing is the same: identify, notify, report, escalate.

When the sunset slips: what the post-mortem should record

A slipped sunset is not a failure of the contract. It is a failure of the audit or the exception process, and the post-mortem should say which. Record four things:

  1. Who was still calling the command on the sunset date. Repository, job, and the person or team responsible. If the list is empty, the slip was procedural, not technical.
  2. Why the warning was missed. Was the caller outside the CI policy check? Was the structured line suppressed by log filtering? Was the repository archived and unowned? Each answer points to a different fix.
  3. Whether an exception was recorded. If the extension was granted verbally, the post-mortem’s first action is to write the exception down and name the new date.
  4. What changes to the contract next time. A longer window for auth-adjacent commands, a broader static search, a new entry in the onboarding guide. The post-mortem should produce one concrete change, not a resolution to communicate better.

The pattern to avoid is the one where the post-mortem concludes that the team “needs to improve communication.” That is not a finding. The finding is that the warning was on stderr and the CI job redirected stderr to /dev/null, or that the command was invoked from a vendored script outside the monorepo. Those are fixable.

The decision matrix: when 90 days is too short, and when it is generous

The 90-day default is a starting point, not a rule. The inputs that should move it:

Input Pushes the window longer Pushes it shorter
Blast radius Command is used in release or deploy paths Command is used in local dev only
Replacement maturity Replacement is new or has known gaps Replacement has been default for months
Caller count Many repositories, some unowned One or two known callers
Migration effort Requires code changes, not just a flag swap Drop-in replacement
Auth or credentials Command touches tokens, secrets, or access Command is read-only and local

For context on the range: Google’s guidance for a consumer product recommended starting notifications at least 30 days before deprecation (Google Developers). Google’s API versioning guidance recommends 180 days for beta-channel functionality (AIP-185). GitHub’s REST API supports a previous version for at least 24 months after a new version is released (GitHub Docs, API Versions). Zuplo’s guide recommends announcing at least six months before shutdown (Zuplo). These are different contexts with different constraints. None of them is a rule for internal CLI commands. The point of the matrix is to make the choice explicit and defensible, not to import someone else’s number.

If two rows of the matrix point in opposite directions and the difference is within a week or two of migration effort, pick the shorter window and move on. The contract’s value is in the checkpoints and the audit, not in the precision of the date.

What the contract does not solve

It does not make people read banners. It does not replace a migration guide, and it does not fix a replacement command that is worse than the one being removed. If the replacement is not ready, the sunset date is the wrong date, and the correct move is to delay the announcement rather than announce a date the team cannot keep.

It also does not apply cleanly to commands with no replacement. A command removed because the underlying service is gone has a different contract: the banner names the date and the reason, and the migration doc explains what to do instead of the command. The checkpoints still apply; the replacement field is the one that changes.

The contract is a liability-management tool. It treats the command as something the team owes its callers, with a named owner and a published date. That framing is the point. A toolchain run as a favor produces surprise breakage; a toolchain run as a product produces banners, reports, and a date that holds.

FAQ

Is 90 days a standard deprecation window?

No. The sources cited here give 30 days, 180 days, six months, and 24 months for different contexts. Ninety days is a chosen default for internal CLI commands, published and defended by the owning team. The contract’s value is in the checkpoints and the audit, not in the specific number.

Should the banner go to stdout or stderr?

Stderr. Stdout is the command’s data channel and scripts parse it. A banner on stdout breaks the pipelines the contract is meant to protect. This is a design recommendation, not a claim about any specific tool’s behavior.

What if a team cannot migrate by the sunset date?

They request a written exception. The exception records the caller, the reason, and a new date. An extension granted verbally is a date that was never real, and it should be treated as a process failure in the post-mortem.

Does SemVer already handle this?

Partly. SemVer requires that deprecation increments the minor version and that at least one minor release containing the deprecation precedes removal in a major release (SemVer 2.0.0). It does not specify a calendar. The contract supplies the date, the owner, and the audit that SemVer leaves open.

How is this different from an API sunset?

The two-stage model is the same: deprecation does not change behavior, and sunset does (RFC 8594, §1.4; Deprecation header draft, §5). The delivery mechanism is different. HTTP uses response headers; a CLI uses stderr output and exit codes. The CLI banner is an analogy to the header model, not an implementation of it.