Repository: Redocly/redocly-cli
Pull request #1677 (2024-08-22 16:36:34)

feat: report endpoint sunset
## What/Why/How?
Warn user about planned endpoint sunset.

Currently there is no way to let user know about breaking changes in the endpoints or endpoint deprecation used for specific command.

Lookup for `Sunset` header in responses received from Reunite to notify users if there are resources that is going to deprecate or is already deprecated (but is still functioning).   

## Reference

## Testing

## Screenshots (optional)

*Endpoint is about to be deprecated*

<img width="903" alt="365737829-3ab90010-0739-4d3b-8ce2-c0a0505b3be9" src="https://github.com/user-attachments/assets/2a0e5417-f0f8-4b9a-96ed-73123857b20c">

*Endpoint is already deprecated*

<img width="881" alt="image" src="https://github.com/user-attachments/assets/24b052f5-251f-46ad-a522-65b120ee3177">



## Check yourself

- [ ] Code changed? - Tested with redoc/reference-docs/workflows (internal)
- [ ] All new/updated code is covered with tests
- [ ] New package installed? - Tested in different environments (browser/node)

## Security

- [x] Security impact of change has been considered
- [x] Code follows company security practices and guidelines


changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: eef8f4ffc136cbe7bb04034f14b195e0095006d8

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/Redocly/redocly-cli/new/feat/report-endpoint-sunset?filename=.changeset/six-sheep-fetch.md&value=---%0A%22%40redocly%2Fcli%22%3A%20patch%0A---%0A%0AFeat%2Freport%20endpoint%20sunset%0A)


github-actions[bot]: <!-- jest coverage report action for options with hash 3339ae53a38c44382c0fb4491bdb282d -->

## Coverage report 

❌ An unexpected error occurred. For more details, [check console](https://github.com/Redocly/redocly-cli/actions/runs/10512094516) 
```
Error: The process '/opt/hostedtoolcache/node/20.16.0/x64/bin/npm' failed with exit code 1
```

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | Category   | Percentage                                                     | Covered / Total |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :--------- | :------------------------------------------------------------- | :-------------: |
|                                                                           🟡                                                                           | Statements | 76.33%                                                         |    4709/6169    |
|                                                                           🟡                                                                           | Branches   | 65.24%                                                         |    1958/3001    |
|                                                                           🟡                                                                           | Functions  | 70.57%                                                         |     777/1101    |
|                                                                           🟡                                                                           | Lines      | <div title="Base coverage is: 77.48%">76.46% (-1.01% 🔻)</div> |    4444/5812    |

<details><summary>Show new covered files 🐣</summary>
<br/>

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | File                                                                                             | Statements | Branches | Functions | Lines |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------- | :--------- | :------- | :-------- | :---- |
|                                                                           🔴                                                                           | <div title="cli/src/cms/api/middlewares/sunset.middleware.ts">`...` / sunset.middleware.ts</div> | 5.26%      | 0%       | 0%        | 5.26% |

</details>


<details><summary>Show files with reduced coverage 🔻</summary>
<br/>

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | File                                                                          | Statements                                                      | Branches                                                     | Functions                                                     | Lines                                                          |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------- | :-------------------------------------------------------------- | :----------------------------------------------------------- | :------------------------------------------------------------ | :------------------------------------------------------------- |
|                                                                           🔴                                                                           | <div title="cli/src/cms/commands/push.ts">`...` / push.ts</div>               | <div title="Base coverage is: 87.14%">12.16% (-74.98% 🔻)</div> | <div title="Base coverage is: 58.33%">0% (-58.33% 🔻)</div>  | <div title="Base coverage is: 100%">0% (-100% 🔻)</div>       | <div title="Base coverage is: 86.76%">12.5% (-74.26% 🔻)</div> |
|                                                                           🟢                                                                           | <div title="cli/src/cms/commands/push-status.ts">`...` / push-status.ts</div> | <div title="Base coverage is: 91.57%">92.59% (+1.03% 🔼)</div>  | 76.56%                                                       | 100%                                                          | <div title="Base coverage is: 91.57%">92.59% (+1.03% 🔼)</div> |
|                                                                           🟡                                                                           | <div title="cli/src/cms/api/api-client.ts">`...` / api-client.ts</div>        | <div title="Base coverage is: 75.44%">76.71% (+1.27% 🔼)</div>  | <div title="Base coverage is: 41.38%">50% (+8.62% 🔼)</div>  | <div title="Base coverage is: 75%">73.33% (-1.67% 🔻)</div>   | <div title="Base coverage is: 74.55%">77.46% (+2.92% 🔼)</div> |
|                                                                           🔴                                                                           | <div title="cli/src/cms/commands/utils.ts">`...` / utils.ts</div>             | <div title="Base coverage is: 100%">51.16% (-48.84% 🔻)</div>   | <div title="Base coverage is: 75%">36.36% (-38.64% 🔻)</div> | <div title="Base coverage is: 100%">42.86% (-57.14% 🔻)</div> | <div title="Base coverage is: 100%">51.16% (-48.84% 🔻)</div>  |

</details>

## Test suite run failed

<details><summary>Failed tests: 0/768. Failed suites: 1/110.</summary>
<br/>

```
  ● Test suite failed to run

    Jest worker encountered 4 child process exceptions, exceeding retry limit

      at ChildProcessWorker.initialize (node_modules/jest-worker/build/workers/ChildProcessWorker.js:181:21)

```

</details>


<p align="right">Report generated by <a href="https://github.com/ArtiomTr/jest-coverage-report-action">🧪jest coverage report action</a> from eef8f4ffc136cbe7bb04034f14b195e0095006d8</p>

github-actions[bot]: | Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|:---|---:|---:|---:|---:|
| `redocly lint packages/core/src/benchmark/benches/rebilly.yaml` | 987.9 ± 26.0 | 957.4 | 1052.3 | 1.00 ± 0.04 |
| `redocly-next lint packages/core/src/benchmark/benches/rebilly.yaml` | 986.5 ± 24.9 | 964.8 | 1031.9 | 1.00 |

<!-- thollander/actions-comment-pull-request "latest-vs-next-comparison" -->
lornajane: Can we go back a step, I don't think I understand what's happening here - are we adding a user-friendly outcome in the CLI for what happens when the CLI tries to do something that we've stopped supporting in our API? Is it generic, or specifically for a planned change to the push command? What's the change?

In terms of communicating to users, the user thinks of the CLI has having "commands", they don't care about the implementation details such as endpoints. Can we say "this command is unavailable, updating the CLI tool might help " ?

Patch:
diff --git a/.changeset/tiny-boats-check.md b/.changeset/tiny-boats-check.md
new file mode 100644
index 00000000..8cb64ea5
--- /dev/null
+++ b/.changeset/tiny-boats-check.md
@@ -0,0 +1,5 @@
+---
+"@redocly/cli": patch
+---
+
+Added a warning message to the `push` and `push-status` commands to notify users about upcoming or ongoing resource deprecation.
diff --git a/packages/cli/src/cms/api/api-client.ts b/packages/cli/src/cms/api/api-client.ts
index bdf790bf..3cd0fa56 100644
--- a/packages/cli/src/cms/api/api-client.ts
+++ b/packages/cli/src/cms/api/api-client.ts
@@ -1,3 +1,4 @@
+import { yellow, red } from 'colorette';
 import * as FormData from 'form-data';
 import fetchWithTimeout, {
   type FetchWithTimeoutOptions,
@@ -13,54 +14,100 @@ import type {
   UpsertRemoteResponse,
 } from './types';
 
+interface BaseApiClient {
+  request(url: string, options: FetchWithTimeoutOptions): Promise<Response>;
+}
+type CommandOption = 'push' | 'push-status';
+export type SunsetWarning = { sunsetDate: Date; isSunsetExpired: boolean };
+export type SunsetWarningsBuffer = SunsetWarning[];
+
 export class ReuniteApiError extends Error {
   constructor(message: string, public status: number) {
     super(message);
   }
 }
 
-class ReuniteBaseApiClient {
-  constructor(protected version: string, protected command: string) {}
-
-  protected async getParsedResponse<T>(response: Response): Promise<T> {
-    const responseBody = await response.json();
-
-    if (response.ok) {
-      return responseBody as T;
-    }
+class ReuniteApiClient implements BaseApiClient {
+  public sunsetWarnings: SunsetWarningsBuffer = [];
 
-    throw new ReuniteApiError(
-      `${responseBody.title || response.statusText || 'Unknown error'}.`,
-      response.status
-    );
-  }
+  constructor(protected version: string, protected command: string) {}
 
-  protected request(url: string, options: FetchWithTimeoutOptions) {
+  public async request(url: string, options: FetchWithTimeoutOptions) {
     const headers = {
       ...options.headers,
       'user-agent': `redocly-cli/${this.version.trim()} ${this.command}`,
     };
 
-    return fetchWithTimeout(url, {
+    const response = await fetchWithTimeout(url, {
       ...options,
       headers,
     });
+
+    this.collectSunsetWarning(response);
+
+    return response;
+  }
+
+  private collectSunsetWarning(response: Response) {
+    const sunsetTime = this.getSunsetDate(response);
+
+    if (!sunsetTime) return;
+
+    const sunsetDate = new Date(sunsetTime);
+
+    if (sunsetTime > Date.now()) {
+      this.sunsetWarnings.push({
+        sunsetDate,
+        isSunsetExpired: false,
+      });
+    } else {
+      this.sunsetWarnings.push({
+        sunsetDate,
+        isSunsetExpired: true,
+      });
+    }
+  }
+
+  private getSunsetDate(response: Response): number | undefined {
+    const { headers } = response;
+
+    if (!headers) {
+      return;
+    }
+
+    const sunsetDate = headers.get('sunset') || headers.get('Sunset');
+
+    if (!sunsetDate) {
+      return;
+    }
+
+    return Date.parse(sunsetDate);
   }
 }
 
-class RemotesApiClient extends ReuniteBaseApiClient {
+class RemotesApi {
   constructor(
+    private client: BaseApiClient,
     private readonly domain: string,
-    private readonly apiKey: string,
-    version: string,
-    command: string
-  ) {
-    super(version, command);
+    private readonly apiKey: string
+  ) {}
+
+  protected async getParsedResponse<T>(response: Response): Promise<T> {
+    const responseBody = await response.json();
+
+    if (response.ok) {
+      return responseBody as T;
+    }
+
+    throw new ReuniteApiError(
+      `${responseBody.title || response.statusText || 'Unknown error'}.`,
+      response.status
+    );
   }
 
   async getDefaultBranch(organizationId: string, projectId: string) {
     try {
-      const response = await this.request(
+      const response = await this.client.request(
         `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/source`,
         {
           timeout: DEFAULT_FETCH_TIMEOUT,
@@ -95,7 +142,7 @@ class RemotesApiClient extends ReuniteBaseApiClient {
     }
   ): Promise<UpsertRemoteResponse> {
     try {
-      const response = await this.request(
+      const response = await this.client.request(
         `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes`,
         {
           timeout: DEFAULT_FETCH_TIMEOUT,
@@ -150,7 +197,7 @@ class RemotesApiClient extends ReuniteBaseApiClient {
 
     payload.isMainBranch && formData.append('isMainBranch', 'true');
     try {
-      const response = await this.request(
+      const response = await this.client.request(
         `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes`,
         {
           method: 'POST',
@@ -183,7 +230,7 @@ class RemotesApiClient extends ReuniteBaseApiClient {
     mountPath: string;
   }) {
     try {
-      const response = await this.request(
+      const response = await this.client.request(
         `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes?filter=mountPath:/${mountPath}/`,
         {
           timeout: DEFAULT_FETCH_TIMEOUT,
@@ -217,7 +264,7 @@ class RemotesApiClient extends ReuniteBaseApiClient {
     pushId: string;
   }) {
     try {
-      const response = await this.request(
+      const response = await this.client.request(
         `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes/${pushId}`,
         {
           timeout: DEFAULT_FETCH_TIMEOUT,
@@ -242,8 +289,12 @@ class RemotesApiClient extends ReuniteBaseApiClient {
   }
 }
 
-export class ReuniteApiClient {
-  remotes: RemotesApiClient;
+export class ReuniteApi {
+  private apiClient: ReuniteApiClient;
+  private version: string;
+  private command: CommandOption;
+
+  public remotes: RemotesApi;
 
   constructor({
     domain,
@@ -254,9 +305,49 @@ export class ReuniteApiClient {
     domain: string;
     apiKey: string;
     version: string;
-    command: 'push' | 'push-status';
+    command: CommandOption;
   }) {
-    this.remotes = new RemotesApiClient(domain, apiKey, version, command);
+    this.command = command;
+    this.version = version;
+    this.apiClient = new ReuniteApiClient(this.version, this.command);
+
+    this.remotes = new RemotesApi(this.apiClient, domain, apiKey);
+  }
+
+  public reportSunsetWarnings(): void {
+    const sunsetWarnings = this.apiClient.sunsetWarnings;
+
+    if (sunsetWarnings.length) {
+      const [{ isSunsetExpired, sunsetDate }] = sunsetWarnings.sort(
+        (a: SunsetWarning, b: SunsetWarning) => {
+          // First, prioritize by expiration status
+          if (a.isSunsetExpired !== b.isSunsetExpired) {
+            return a.isSunsetExpired ? -1 : 1;
+          }
+
+          // If both are either expired or not, sort by sunset date
+          return a.sunsetDate > b.sunsetDate ? 1 : -1;
+        }
+      );
+
+      const updateVersionMessage = `Update to the latest version by running "npm install @redocly/cli@latest".`;
+
+      if (isSunsetExpired) {
+        process.stdout.write(
+          red(
+            `The "${this.command}" command is not compatible with your version of Redocly CLI. ${updateVersionMessage}\n\n`
+          )
+        );
+      } else {
+        process.stdout.write(
+          yellow(
+            `The "${
+              this.command
+            }" command will be incompatible with your version of Redocly CLI after ${sunsetDate.toLocaleString()}. ${updateVersionMessage}\n\n`
+          )
+        );
+      }
+    }
   }
 }
 
diff --git a/packages/cli/src/cms/commands/push-status.ts b/packages/cli/src/cms/commands/push-status.ts
index f819d18c..7ead6834 100644
--- a/packages/cli/src/cms/commands/push-status.ts
+++ b/packages/cli/src/cms/commands/push-status.ts
@@ -2,7 +2,7 @@ import * as colors from 'colorette';
 import { exitWithError, printExecutionTime } from '../../utils/miscellaneous';
 import { Spinner } from '../../utils/spinner';
 import { DeploymentError } from '../utils';
-import { ReuniteApiClient, getApiKeys, getDomain } from '../api';
+import { ReuniteApi, getApiKeys, getDomain } from '../api';
 import { capitalize } from '../../utils/js-utils';
 import { handleReuniteError, retryUntilConditionMet } from './utils';
 
@@ -68,7 +68,7 @@ export async function handlePushStatus({
 
   try {
     const apiKey = getApiKeys(domain);
-    const client = new ReuniteApiClient({ domain, apiKey, version, command: 'push-status' });
+    const client = new ReuniteApi({ domain, apiKey, version, command: 'push-status' });
 
     let pushResponse: PushResponse;
 
@@ -169,6 +169,8 @@ export async function handlePushStatus({
     }
     printPushStatusInfo({ orgId, projectId, pushId, startedAt });
 
+    client.reportSunsetWarnings();
+
     const summary: PushStatusSummary = {
       preview: pushResponse.status.preview,
       production: pushResponse.isMainBranch ? pushResponse.status.production : null,
diff --git a/packages/cli/src/cms/commands/push.ts b/packages/cli/src/cms/commands/push.ts
index bef249c0..0d5cee5a 100644
--- a/packages/cli/src/cms/commands/push.ts
+++ b/packages/cli/src/cms/commands/push.ts
@@ -5,7 +5,7 @@ import { pluralize } from '@redocly/openapi-core/lib/utils';
 import { green, yellow } from 'colorette';
 import { exitWithError, printExecutionTime } from '../../utils/miscellaneous';
 import { handlePushStatus } from './push-status';
-import { ReuniteApiClient, getDomain, getApiKeys } from '../api';
+import { ReuniteApi, getDomain, getApiKeys } from '../api';
 import { handleReuniteError } from './utils';
 
 import type { OutputFormat } from '@redocly/openapi-core';
@@ -81,12 +81,13 @@ export async function handlePush({
     const author = parseCommitAuthor(argv.author);
     const apiKey = getApiKeys(domain);
     const filesToUpload = collectFilesToPush(argv.files || argv.apis);
+    const commandName = 'push' as const;
 
     if (!filesToUpload.length) {
-      return printExecutionTime('push', startedAt, `No files to upload`);
+      return printExecutionTime(commandName, startedAt, `No files to upload`);
     }
 
-    const client = new ReuniteApiClient({ domain, apiKey, version, command: 'push' });
+    const client = new ReuniteApi({ domain, apiKey, version, command: commandName });
     const projectDefaultBranch = await client.remotes.getDefaultBranch(orgId, projectId);
     const remote = await client.remotes.upsert(orgId, projectId, {
       mountBranchName: projectDefaultBranch,
@@ -147,7 +148,7 @@ export async function handlePush({
     }
     verbose &&
       printExecutionTime(
-        'push',
+        commandName,
         startedAt,
         `${pluralize(
           'file',
@@ -155,6 +156,8 @@ export async function handlePush({
         )} uploaded to organization ${orgId}, project ${projectId}. Push ID: ${id}.`
       );
 
+    client.reportSunsetWarnings();
+
     return {
       pushId: id,
     };


Test patch:
diff --git a/packages/cli/src/cms/api/__tests__/api.client.test.ts b/packages/cli/src/cms/api/__tests__/api.client.test.ts
index f85a341e..77d4e5a3 100644
--- a/packages/cli/src/cms/api/__tests__/api.client.test.ts
+++ b/packages/cli/src/cms/api/__tests__/api.client.test.ts
@@ -1,7 +1,8 @@
 import fetch, { Response } from 'node-fetch';
 import * as FormData from 'form-data';
+import { red, yellow } from 'colorette';
 
-import { ReuniteApiClient, PushPayload, ReuniteApiError } from '../api-client';
+import { ReuniteApi, PushPayload, ReuniteApiError } from '../api-client';
 
 jest.mock('node-fetch', () => ({
   default: jest.fn(),
@@ -21,10 +22,10 @@ describe('ApiClient', () => {
   const expectedUserAgent = `redocly-cli/${version} ${command}`;
 
   describe('getDefaultBranch()', () => {
-    let apiClient: ReuniteApiClient;
+    let apiClient: ReuniteApi;
 
     beforeEach(() => {
-      apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
+      apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command });
     });
 
     it('should get default project branch', async () => {
@@ -90,22 +91,23 @@ describe('ApiClient', () => {
       mountBranchName: 'remote-mount-branch-name',
       mountPath: 'remote-mount-path',
     };
-    let apiClient: ReuniteApiClient;
+
+    const responseMock = {
+      id: 'remote-id',
+      type: 'CICD',
+      mountPath: 'remote-mount-path',
+      mountBranchName: 'remote-mount-branch-name',
+      organizationId: testOrg,
+      projectId: testProject,
+    };
+
+    let apiClient: ReuniteApi;
 
     beforeEach(() => {
-      apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
+      apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command });
     });
 
     it('should upsert remote', async () => {
-      const responseMock = {
-        id: 'remote-id',
-        type: 'CICD',
-        mountPath: 'remote-mount-path',
-        mountBranchName: 'remote-mount-branch-name',
-        organizationId: testOrg,
-        projectId: testProject,
-      };
-
       mockFetchResponse({
         ok: true,
         json: jest.fn().mockResolvedValue(responseMock),
@@ -204,10 +206,10 @@ describe('ApiClient', () => {
       outdated: false,
     };
 
-    let apiClient: ReuniteApiClient;
+    let apiClient: ReuniteApi;
 
     beforeEach(() => {
-      apiClient = new ReuniteApiClient({ domain: testDomain, apiKey: testToken, version, command });
+      apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command });
     });
 
     it('should push to remote', async () => {
@@ -284,4 +286,165 @@ describe('ApiClient', () => {
       ).rejects.toThrow(new ReuniteApiError('Failed to push. Not found.', 404));
     });
   });
+
+  describe('Sunset header', () => {
+    const upsertRemoteMock = {
+      requestFn: () =>
+        apiClient.remotes.upsert(testOrg, testProject, {
+          mountBranchName: 'remote-mount-branch-name',
+          mountPath: 'remote-mount-path',
+        }),
+      responseBody: {
+        id: 'remote-id',
+        type: 'CICD',
+        mountPath: 'remote-mount-path',
+        mountBranchName: 'remote-mount-branch-name',
+        organizationId: testOrg,
+        projectId: testProject,
+      },
+    };
+
+    const getDefaultBranchMock = {
+      requestFn: () => apiClient.remotes.getDefaultBranch(testOrg, testProject),
+      responseBody: {
+        branchName: 'test-branch',
+      },
+    };
+
+    const pushMock = {
+      requestFn: () =>
+        apiClient.remotes.push(
+          testOrg,
+          testProject,
+          {
+            remoteId: 'test-remote-id',
+            commit: {
+              message: 'test-message',
+              author: {
+                name: 'test-name',
+                email: 'test-email',
+              },
+              branchName: 'test-branch-name',
+            },
+          },
+          [{ path: 'some-file.yaml', stream: Buffer.from('text content') }]
+        ),
+      responseBody: {
+        branchName: 'rem/cicd/rem_01he7sr6ys2agb7w0g9t7978fn-main',
+        hasChanges: true,
+        files: [
+          {
+            type: 'file',
+            name: 'some-file.yaml',
+            path: 'docs/remotes/some-file.yaml',
+            lastModified: 1698925132394.2993,
+            mimeType: 'text/yaml',
+          },
+        ],
+        commitSha: 'bb23a2f8e012ac0b7b9961b57fb40d8686b21b43',
+        outdated: false,
+      },
+    };
+
+    const endpointMocks = [upsertRemoteMock, getDefaultBranchMock, pushMock];
+
+    let apiClient: ReuniteApi;
+
+    beforeEach(() => {
+      apiClient = new ReuniteApi({ domain: testDomain, apiKey: testToken, version, command });
+    });
+
+    it.each(endpointMocks)(
+      'should report endpoint sunset in the past',
+      async ({ responseBody, requestFn }) => {
+        jest.spyOn(process.stdout, 'write').mockImplementationOnce(() => true);
+        const sunsetDate = new Date('2024-09-06T12:30:32.456Z');
+
+        mockFetchResponse({
+          ok: true,
+          json: jest.fn().mockResolvedValue(responseBody),
+          headers: new Headers({
+            Sunset: sunsetDate.toISOString(),
+          }),
+        });
+
+        await requestFn();
+        apiClient.reportSunsetWarnings();
+
+        expect(process.stdout.write).toHaveBeenCalledWith(
+          red(
+            `The "push" command is not compatible with your version of Redocly CLI. Update to the latest version by running "npm install @redocly/cli@latest".\n\n`
+          )
+        );
+      }
+    );
+
+    it.each(endpointMocks)(
+      'should report endpoint sunset in the future',
+      async ({ responseBody, requestFn }) => {
+        jest.spyOn(process.stdout, 'write').mockImplementationOnce(() => true);
+        const sunsetDate = new Date(Date.now() + 1000 * 60 * 60 * 24);
+
+        mockFetchResponse({
+          ok: true,
+          json: jest.fn().mockResolvedValue(responseBody),
+          headers: new Headers({
+            Sunset: sunsetDate.toISOString(),
+          }),
+        });
+
+        await requestFn();
+        apiClient.reportSunsetWarnings();
+
+        expect(process.stdout.write).toHaveBeenCalledWith(
+          yellow(
+            `The "push" command will be incompatible with your version of Redocly CLI after ${sunsetDate.toLocaleString()}. Update to the latest version by running "npm install @redocly/cli@latest".\n\n`
+          )
+        );
+      }
+    );
+
+    it('should report only expired resource', async () => {
+      jest.spyOn(process.stdout, 'write').mockImplementationOnce(() => true);
+
+      mockFetchResponse({
+        ok: true,
+        json: jest.fn().mockResolvedValue(upsertRemoteMock.responseBody),
+        headers: new Headers({
+          Sunset: new Date('2024-08-06T12:30:32.456Z').toISOString(),
+        }),
+      });
+
+      await upsertRemoteMock.requestFn();
+
+      mockFetchResponse({
+        ok: true,
+        json: jest.fn().mockResolvedValue(getDefaultBranchMock.responseBody),
+        headers: new Headers({
+          Sunset: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(),
+        }),
+      });
+
+      await getDefaultBranchMock.requestFn();
+
+      mockFetchResponse({
+        ok: true,
+        json: jest.fn().mockResolvedValue(pushMock.responseBody),
+        headers: new Headers({
+          Sunset: new Date('2024-08-06T12:30:32.456Z').toISOString(),
+        }),
+      });
+
+      await pushMock.requestFn();
+
+      apiClient.reportSunsetWarnings();
+
+      expect(process.stdout.write).toHaveBeenCalledTimes(1);
+      expect(process.stdout.write).toHaveBeenCalledWith(
+        red(
+          `The "push" command is not compatible with your version of Redocly CLI. Update to the latest version by running "npm install @redocly/cli@latest".\n\n`
+        )
+      );
+    });
+  });
 });
diff --git a/packages/cli/src/cms/commands/__tests__/push-status.test.ts b/packages/cli/src/cms/commands/__tests__/push-status.test.ts
index db02dcbc..f36a7a34 100644
--- a/packages/cli/src/cms/commands/__tests__/push-status.test.ts
+++ b/packages/cli/src/cms/commands/__tests__/push-status.test.ts
@@ -17,8 +17,9 @@ jest.mock('colorette', () => ({
 
 jest.mock('../../api', () => ({
   ...jest.requireActual('../../api'),
-  ReuniteApiClient: jest.fn().mockImplementation(function (this: any, ...args) {
+  ReuniteApi: jest.fn().mockImplementation(function (this: any, ...args) {
     this.remotes = remotes;
+    this.reportSunsetWarnings = jest.fn();
   }),
 }));
 
diff --git a/packages/cli/src/cms/commands/__tests__/push.test.ts b/packages/cli/src/cms/commands/__tests__/push.test.ts
index 31870d69..d6deda23 100644
--- a/packages/cli/src/cms/commands/__tests__/push.test.ts
+++ b/packages/cli/src/cms/commands/__tests__/push.test.ts
@@ -1,7 +1,7 @@
 import * as fs from 'fs';
 import * as path from 'path';
 import { handlePush } from '../push';
-import { ReuniteApiClient, ReuniteApiError } from '../../api';
+import { ReuniteApi, ReuniteApiError } from '../../api';
 
 const remotes = {
   push: jest.fn(),
@@ -15,8 +15,9 @@ jest.mock('@redocly/openapi-core', () => ({
 
 jest.mock('../../api', () => ({
   ...jest.requireActual('../../api'),
-  ReuniteApiClient: jest.fn().mockImplementation(function (this: any, ...args) {
+  ReuniteApi: jest.fn().mockImplementation(function (this: any, ...args) {
     this.remotes = remotes;
+    this.reportSunsetWarnings = jest.fn();
   }),
 }));
 
@@ -332,7 +333,7 @@ describe('handlePush()', () => {
       version: 'cli-version',
     });
 
-    expect(ReuniteApiClient).toBeCalledWith({
+    expect(ReuniteApi).toBeCalledWith({
       domain: 'test-domain-from-env',
       apiKey: 'test-api-key',
       version: 'cli-version',