Skip to content

Commit d43b008

Browse files
authored
Remove stale staging apps (github#19034)
* Add a script to remove stale staging apps * Add a workflow to remove stale staging apps on a regular basis
1 parent fe5d42b commit d43b008

2 files changed

Lines changed: 162 additions & 0 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Remove stale staging apps
2+
3+
# **What it does**:
4+
# This cleans up any rogue staging applications that outlasted the closure of
5+
# their corresponding pull requests.
6+
# **Why we have it**:
7+
# Staging applications sometimes fail to be destroyed when their corresponding
8+
# pull request is closed or merged.
9+
# **Who does it impact**:
10+
# Anyone with a closed, spammy, or deleted pull request in docs or docs-internal.
11+
12+
on:
13+
schedule:
14+
- cron: '15,45 * * * *' # every thirty minutes at :15 and :45
15+
16+
jobs:
17+
remove_stale_staging_apps:
18+
name: Remove stale staging apps
19+
if: ${{ github.repository == 'github/docs-internal' }}
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f
23+
- name: npm ci
24+
run: npm ci
25+
- name: Run script
26+
run: script/remove-stale-staging-apps.js
27+
env:
28+
HEROKU_API_TOKEN: ${{ secrets.HEROKU_API_TOKEN }}
29+
GITHUB_TOKEN: ${{ secrets.DOCUBOT_FR_PROJECT_BOARD_WORKFLOWS_REPO_ORG_READ_SCOPES }}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
#!/usr/bin/env node
2+
3+
// [start-readme]
4+
//
5+
// This script remove all stale Heroku staging apps that outlasted the closure
6+
// of their corresponding pull requests.
7+
//
8+
// [end-readme]
9+
10+
require('dotenv').config()
11+
12+
const { chain } = require('lodash')
13+
const chalk = require('chalk')
14+
const Heroku = require('heroku-client')
15+
const { Octokit } = require('@octokit/rest')
16+
17+
// Check for required Heroku API token
18+
if (!process.env.HEROKU_API_TOKEN) {
19+
console.error('Error! You must have a HEROKU_API_TOKEN environment variable for deployer-level access.')
20+
process.exit(1)
21+
}
22+
// Check for required GitHub PAT
23+
if (!process.env.GITHUB_TOKEN) {
24+
console.error('Error! You must have a GITHUB_TOKEN environment variable for repo access.')
25+
process.exit(1)
26+
}
27+
28+
const heroku = new Heroku({ token: process.env.HEROKU_API_TOKEN })
29+
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN })
30+
31+
const protectedAppNames = ['help-docs', 'help-docs-deployer']
32+
33+
main()
34+
35+
async function main () {
36+
const apps = chain(await heroku.get('/apps'))
37+
.orderBy('name')
38+
.value()
39+
40+
const prInfoMatch = /^(?<repo>docs(?:-internal)?)-(?<pullNumber>\d+)--.*$/
41+
42+
const appsPlusPullIds = apps
43+
.map(app => {
44+
const match = prInfoMatch.exec(app.name)
45+
const { repo, pullNumber } = ((match || {}).groups || {})
46+
47+
return {
48+
app,
49+
repo,
50+
pullNumber: parseInt(pullNumber, 10) || null
51+
}
52+
})
53+
54+
const appsWithPullIds = appsPlusPullIds.filter(appi => appi.repo && appi.pullNumber > 0)
55+
56+
const nonMatchingAppNames = appsPlusPullIds
57+
.filter(appi => !(appi.repo && appi.pullNumber > 0))
58+
.map(appi => appi.app.name)
59+
.filter(name => !protectedAppNames.includes(name))
60+
61+
let staleCount = 0
62+
let spammyCount = 0
63+
for (const awpi of appsWithPullIds) {
64+
const { isStale, isSpammy } = await assessPullRequest(awpi.repo, awpi.pullNumber)
65+
66+
if (isSpammy) spammyCount++
67+
if (isStale) {
68+
staleCount++
69+
await deleteHerokuApp(awpi.app.name)
70+
}
71+
}
72+
73+
const matchingCount = appsWithPullIds.length
74+
const counts = {
75+
total: matchingCount,
76+
alive: matchingCount - staleCount,
77+
stale: {
78+
total: staleCount,
79+
spammy: spammyCount,
80+
closed: staleCount - spammyCount
81+
}
82+
}
83+
console.log(`🧮 COUNTS!\n${JSON.stringify(counts, null, 2)}`)
84+
85+
const nonMatchingCount = nonMatchingAppNames.length
86+
if (nonMatchingCount > 0) {
87+
console.log('⚠️ 👀', chalk.yellow(`Non-matching app names (${nonMatchingCount}):\n - ${nonMatchingAppNames.join('\n - ')}`))
88+
}
89+
}
90+
91+
function displayParams (params) {
92+
const { owner, repo, pull_number: pullNumber } = params
93+
return `${owner}/${repo}#${pullNumber}`
94+
}
95+
96+
async function assessPullRequest (repo, pullNumber) {
97+
const params = {
98+
owner: 'github',
99+
repo: repo,
100+
pull_number: pullNumber
101+
}
102+
103+
let isStale = false
104+
let isSpammy = false
105+
try {
106+
const { data: pullRequest } = await octokit.pulls.get(params)
107+
108+
if (pullRequest && pullRequest.state === 'closed') {
109+
isStale = true
110+
console.debug(chalk.green(`STALE: ${displayParams(params)} is closed`))
111+
}
112+
} catch (error) {
113+
// Using a standard GitHub PAT, PRs from spammy users will respond as 404
114+
if (error.status === 404) {
115+
isStale = true
116+
isSpammy = true
117+
console.debug(chalk.yellow(`STALE: ${displayParams(params)} is spammy or deleted`))
118+
} else {
119+
console.debug(chalk.red(`ERROR: ${displayParams(params)} - ${error.message}`))
120+
}
121+
}
122+
123+
return { isStale, isSpammy }
124+
}
125+
126+
async function deleteHerokuApp (appName) {
127+
try {
128+
await heroku.delete(`/apps/${appName}`)
129+
console.log('✅', chalk.green(`Removed stale app "${appName}"`))
130+
} catch (error) {
131+
console.log('❌', chalk.red(`ERROR: Failed to remove stale app "${appName}" - ${error.message}`))
132+
}
133+
}

0 commit comments

Comments
 (0)