Skip to content

Commit b3332de

Browse files
authored
feat: Automatic preview cleanups (#10785)
1 parent e311ea8 commit b3332de

File tree

2 files changed

+286
-0
lines changed

2 files changed

+286
-0
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Delete Stale Vercel Preview Deployments
2+
3+
on:
4+
# Run the workflow daily at 2:00 AM UTC
5+
schedule:
6+
- cron: '0 2 * * *'
7+
# Allows manual triggering of the workflow
8+
workflow_dispatch:
9+
10+
jobs:
11+
delete-stale-deployments:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- name: Checkout repository
15+
uses: actions/checkout@v3
16+
17+
- name: Install bun
18+
uses: oven-sh/setup-bun@v1
19+
with:
20+
bun-version: latest
21+
22+
- name: Run cleanup script for user docs
23+
env:
24+
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID_USER_DOCS }}
25+
VERCEL_API_TOKEN: ${{ secrets.VERCEL_API_TOKEN }}
26+
VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }}
27+
run: bun scripts/preview-deployment-cleanup.ts
28+
29+
- name: Run cleanup script for developer docs
30+
env:
31+
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID_DEVELOP_DOCS }}
32+
VERCEL_API_TOKEN: ${{ secrets.VERCEL_API_TOKEN }}
33+
VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }}
34+
run: bun scripts/preview-deployment-cleanup.ts

scripts/preview-deployment-cleanup.ts

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
/* eslint-disable no-console */
2+
const {VERCEL_PROJECT_ID, VERCEL_API_TOKEN, VERCEL_TEAM_ID} = process.env;
3+
4+
if (!VERCEL_PROJECT_ID || !VERCEL_API_TOKEN || !VERCEL_TEAM_ID) {
5+
console.error('Missing env vars');
6+
}
7+
8+
const VERCEL_API = `https://api.vercel.com`;
9+
10+
const VERCEL_HEADERS = {
11+
Authorization: `Bearer ${VERCEL_API_TOKEN}`,
12+
'Content-Type': 'application/json',
13+
};
14+
15+
// Keep deployments that use these urls
16+
const SKIP_LIST = ['sentry-docs-es5gn0iog.sentry.dev'];
17+
18+
/** This object contains information related to the pagination of the current request, including the necessary parameters to get the next or previous page of data. */
19+
interface Pagination {
20+
/** Amount of items in the current page. */
21+
count: number;
22+
/** Timestamp that must be used to request the next page. */
23+
next: number | null;
24+
/** Timestamp that must be used to request the previous page. */
25+
prev: number | null;
26+
}
27+
28+
interface Response {
29+
deployments: {
30+
/** Timestamp of when the deployment got created. */
31+
created: number;
32+
/** Metadata information of the user who created the deployment. */
33+
creator: {
34+
/** The unique identifier of the user. */
35+
uid: string;
36+
/** The email address of the user. */
37+
email?: string;
38+
/** The GitHub login of the user. */
39+
githubLogin?: string;
40+
/** The GitLab login of the user. */
41+
gitlabLogin?: string;
42+
/** The username of the user. */
43+
username?: string;
44+
};
45+
/** Vercel URL to inspect the deployment. */
46+
inspectorUrl: string | null;
47+
/** The name of the deployment. */
48+
name: string;
49+
/** The type of the deployment. */
50+
type: 'LAMBDAS';
51+
/** The unique identifier of the deployment. */
52+
uid: string;
53+
/** The URL of the deployment. */
54+
url: string;
55+
aliasAssigned?: (number | boolean) | null;
56+
/** An error object in case aliasing of the deployment failed. */
57+
aliasError?: {
58+
code: string;
59+
message: string;
60+
} | null;
61+
/** Timestamp of when the deployment started building at. */
62+
buildingAt?: number;
63+
/** Conclusion for checks */
64+
checksConclusion?: 'succeeded' | 'failed' | 'skipped' | 'canceled';
65+
/** State of all registered checks */
66+
checksState?: 'registered' | 'running' | 'completed';
67+
/** The ID of Vercel Connect configuration used for this deployment */
68+
connectConfigurationId?: string;
69+
/** Timestamp of when the deployment got created. */
70+
createdAt?: number;
71+
/** Deployment can be used for instant rollback */
72+
isRollbackCandidate?: boolean | null;
73+
/** An object containing the deployment's metadata */
74+
meta?: {[key: string]: string};
75+
/** The project settings which was used for this deployment */
76+
projectSettings?: {
77+
buildCommand?: string | null;
78+
commandForIgnoringBuildStep?: string | null;
79+
createdAt?: number;
80+
devCommand?: string | null;
81+
framework?:
82+
| (
83+
| 'blitzjs'
84+
| 'nextjs'
85+
| 'gatsby'
86+
| 'remix'
87+
| 'astro'
88+
| 'hexo'
89+
| 'eleventy'
90+
| 'docusaurus-2'
91+
| 'docusaurus'
92+
| 'preact'
93+
| 'solidstart'
94+
| 'dojo'
95+
| 'ember'
96+
| 'vue'
97+
| 'scully'
98+
| 'ionic-angular'
99+
| 'angular'
100+
| 'polymer'
101+
| 'svelte'
102+
| 'sveltekit'
103+
| 'sveltekit-1'
104+
| 'ionic-react'
105+
| 'create-react-app'
106+
| 'gridsome'
107+
| 'umijs'
108+
| 'sapper'
109+
| 'saber'
110+
| 'stencil'
111+
| 'nuxtjs'
112+
| 'redwoodjs'
113+
| 'hugo'
114+
| 'jekyll'
115+
| 'brunch'
116+
| 'middleman'
117+
| 'zola'
118+
| 'hydrogen'
119+
| 'vite'
120+
| 'vitepress'
121+
| 'vuepress'
122+
| 'parcel'
123+
| 'sanity'
124+
)
125+
| null;
126+
gitForkProtection?: boolean;
127+
gitLFS?: boolean;
128+
installCommand?: string | null;
129+
nodeVersion?: '18.x' | '16.x' | '14.x' | '12.x' | '10.x';
130+
outputDirectory?: string | null;
131+
publicSource?: boolean | null;
132+
rootDirectory?: string | null;
133+
serverlessFunctionRegion?: string | null;
134+
skipGitConnectDuringLink?: boolean;
135+
sourceFilesOutsideRootDirectory?: boolean;
136+
};
137+
/** Timestamp of when the deployment got ready. */
138+
ready?: number;
139+
/** The source of the deployment. */
140+
source?: 'cli' | 'git' | 'import' | 'import/repo' | 'clone/repo';
141+
/** In which state is the deployment. */
142+
state?: 'BUILDING' | 'ERROR' | 'INITIALIZING' | 'QUEUED' | 'READY' | 'CANCELED';
143+
/** On which environment has the deployment been deployed to. */
144+
target?: ('production' | 'staging') | null;
145+
}[];
146+
pagination: Pagination;
147+
}
148+
149+
interface RateLimitError {
150+
error: {
151+
code: string;
152+
limit: {
153+
remaining: number;
154+
reset: number;
155+
resetMs: number;
156+
total: number;
157+
};
158+
message: string;
159+
};
160+
}
161+
162+
const timestampThirtyDaysAgo = () => {
163+
const now = new Date();
164+
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).getTime();
165+
};
166+
167+
const deleteDeployment = ({deploymentId}: {deploymentId: string}) => {
168+
return fetch(`${VERCEL_API}/v13/deployments/${deploymentId}?teamId=${VERCEL_TEAM_ID}`, {
169+
method: 'DELETE',
170+
headers: {...VERCEL_HEADERS},
171+
});
172+
};
173+
174+
const listDeployments = async ({limit = 40, until}: {until: number; limit?: number}) => {
175+
try {
176+
const deploymentsResponse = await fetch(
177+
`${VERCEL_API}/v6/deployments?teamId=${VERCEL_TEAM_ID}&projectId=${VERCEL_PROJECT_ID}&limit=${limit}&until=${until}`,
178+
{
179+
method: 'GET',
180+
headers: {...VERCEL_HEADERS},
181+
}
182+
);
183+
184+
if (!deploymentsResponse.ok) {
185+
console.error('🚨 Could not fetch deployments');
186+
}
187+
188+
return (await deploymentsResponse.json()) as Response;
189+
} catch (err) {
190+
const error = new Error(`🚨 Error fetching deployments`, {
191+
cause: err,
192+
});
193+
194+
throw error;
195+
}
196+
};
197+
198+
let deleteCount = 0;
199+
200+
const run = async ({until}: {until: number}) => {
201+
console.log('🗓️ Deleting stale deployments until ', new Date(until).toISOString());
202+
let rateLimit: number | undefined = undefined;
203+
let deleteCountForTimeframe = 0;
204+
205+
// list deployments based until certain creation date
206+
const {deployments, pagination} = await listDeployments({
207+
until,
208+
limit: 40,
209+
});
210+
211+
// only delete non-skipped preview deployments
212+
const deploymentsForDeletion = deployments.filter(
213+
({target, url}) => target !== 'production' && !SKIP_LIST.includes(url)
214+
);
215+
216+
// delete deployments in sequence to avoid rate limiting
217+
for (let x = 0; x < deploymentsForDeletion.length; x++) {
218+
const deployment = deploymentsForDeletion[x];
219+
const {uid, url} = deployment;
220+
221+
try {
222+
console.log(`\t🧹🧹..deleting deployment ${url} with id ${uid}`);
223+
const deleteRes = await deleteDeployment({deploymentId: uid});
224+
if (deleteRes.status === 429) {
225+
const {error} = (await deleteRes.json()) as RateLimitError;
226+
rateLimit = error.limit.reset * 1000 - Date.now();
227+
break;
228+
}
229+
deleteCountForTimeframe += 1;
230+
deleteCount += 1;
231+
} catch (e) {
232+
console.log(`\t🚨 Could not delete deployment on ${url} with id ${uid}`, e);
233+
}
234+
}
235+
236+
// Wait for the rate limit to reset or wait a default amount of time
237+
const defaultWaitTime = deleteCountForTimeframe === 0 ? 1000 : 40 * 1000;
238+
const timeout = rateLimit ? rateLimit : defaultWaitTime;
239+
if (timeout > 0) {
240+
console.log(`⏱️ Waiting for ${Math.round(timeout / 1000)} seconds`);
241+
await new Promise(resolve => setTimeout(resolve, timeout));
242+
}
243+
244+
if (rateLimit === undefined && pagination.next === null) {
245+
console.log(`✅ Deleted ${deleteCount} deployments`);
246+
return;
247+
}
248+
run({until: rateLimit ? until : (pagination.next as number)});
249+
};
250+
251+
// start deleting deployments that are older than 30d
252+
run({until: timestampThirtyDaysAgo()});

0 commit comments

Comments
 (0)