* Update remove-stale-staging-resources workflow to completely replace undeploy workflow * Delete the staging-undeploy-pr workflow file * Delete all undeployment scripts and logic * Remove all references to the automated-block-deploy label used for undeployment * Simplify staging cross-workflow concurrency needs
267 lines
7.4 KiB
JavaScript
Executable File
267 lines
7.4 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
// [start-readme]
|
|
//
|
|
// This script removes all stale GitHub deployment environments that outlasted
|
|
// the closure of their corresponding pull requests, or correspond to spammy
|
|
// pull requests.
|
|
//
|
|
// [end-readme]
|
|
|
|
import dotenv from 'dotenv'
|
|
import chalk from 'chalk'
|
|
import getOctokit from './helpers/github.js'
|
|
|
|
dotenv.config()
|
|
|
|
// Check for required GitHub PAT
|
|
if (!process.env.GITHUB_TOKEN) {
|
|
console.error('Error! You must have a GITHUB_TOKEN environment variable for repo access.')
|
|
process.exit(1)
|
|
}
|
|
|
|
if (!process.env.ELEVATED_TOKEN) {
|
|
console.error(
|
|
'Error! You must have a ELEVATED_TOKEN environment variable for removing deployment environments.'
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
if (!process.env.REPO) {
|
|
console.error('Error! You must have a REPO environment variable.')
|
|
process.exit(1)
|
|
}
|
|
|
|
if (!process.env.RUN_ID) {
|
|
console.error('Error! You must have a RUN_ID environment variable.')
|
|
process.exit(1)
|
|
}
|
|
|
|
// This helper uses the `GITHUB_TOKEN` implicitly
|
|
const octokit = getOctokit()
|
|
|
|
const protectedEnvNames = ['production']
|
|
const maxEnvironmentsToProcess = 50
|
|
|
|
// How long must a PR be closed without being merged to be considered stale?
|
|
const ONE_HOUR = 60 * 60 * 1000
|
|
const prClosureStaleTime = 2 * ONE_HOUR
|
|
|
|
main()
|
|
|
|
async function main() {
|
|
const owner = 'github'
|
|
const [repoOwner, repo] = (process.env.REPO || '').split('/')
|
|
|
|
if (repoOwner !== owner) {
|
|
console.error(`Error! The repository owner must be "${owner}" but was "${repoOwner}".`)
|
|
process.exit(1)
|
|
}
|
|
|
|
const logUrl = `https://github.com/${owner}/${repo}/actions/runs/${process.env.RUN_ID}`
|
|
|
|
const prInfoMatch = /^(?:gha-|ghd-)?(?<repo>docs(?:-internal)?)-(?<pullNumber>\d+)--.*$/
|
|
|
|
let exceededLimit = false
|
|
let matchingCount = 0
|
|
let staleCount = 0
|
|
let spammyCount = 0
|
|
const nonMatchingEnvNames = []
|
|
|
|
for await (const response of octokit.paginate.iterator(octokit.repos.getAllEnvironments, {
|
|
owner,
|
|
repo,
|
|
})) {
|
|
const { data: environments } = response
|
|
|
|
const envsPlusPullIds = environments.map((env) => {
|
|
const match = prInfoMatch.exec(env.name)
|
|
const { repo: repoName, pullNumber } = (match || {}).groups || {}
|
|
|
|
return {
|
|
env,
|
|
repo: repoName,
|
|
pullNumber: parseInt(pullNumber, 10) || null,
|
|
}
|
|
})
|
|
|
|
const envsWithPullIds = envsPlusPullIds.filter(
|
|
(eppi) => eppi.repo === repo && eppi.pullNumber > 0
|
|
)
|
|
matchingCount += envsWithPullIds.length
|
|
|
|
nonMatchingEnvNames.push(
|
|
...envsPlusPullIds
|
|
.filter((eppi) => !(eppi.repo && eppi.pullNumber > 0))
|
|
.map((eppi) => eppi.env.name)
|
|
.filter((name) => !protectedEnvNames.includes(name))
|
|
)
|
|
|
|
for (const ewpi of envsWithPullIds) {
|
|
const { isStale, isSpammy } = await assessPullRequest(ewpi.pullNumber)
|
|
|
|
if (isSpammy) spammyCount++
|
|
if (isStale) staleCount++
|
|
|
|
if (isSpammy || isStale) {
|
|
await deleteEnvironment(ewpi.env.name)
|
|
}
|
|
|
|
if (spammyCount + staleCount >= maxEnvironmentsToProcess) {
|
|
exceededLimit = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if (exceededLimit) {
|
|
console.log(
|
|
'🛑',
|
|
chalk.bgRed(`STOP! Exceeded limit, halting after ${maxEnvironmentsToProcess}.`)
|
|
)
|
|
break
|
|
}
|
|
}
|
|
|
|
const counts = {
|
|
total: matchingCount,
|
|
alive: matchingCount - staleCount,
|
|
stale: {
|
|
total: staleCount,
|
|
spammy: spammyCount,
|
|
closed: staleCount - spammyCount,
|
|
},
|
|
}
|
|
console.log(`🧮 COUNTS!\n${JSON.stringify(counts, null, 2)}`)
|
|
|
|
const nonMatchingCount = nonMatchingEnvNames.length
|
|
if (nonMatchingCount > 0) {
|
|
console.log(
|
|
'⚠️ 👀',
|
|
chalk.yellow(
|
|
`Non-matching env names (${nonMatchingCount}):\n - ${nonMatchingEnvNames.join('\n - ')}`
|
|
)
|
|
)
|
|
}
|
|
|
|
function displayParams(params) {
|
|
const { owner, repo, pull_number: pullNumber } = params
|
|
return `${owner}/${repo}#${pullNumber}`
|
|
}
|
|
|
|
async function assessPullRequest(pullNumber) {
|
|
const params = {
|
|
owner,
|
|
repo,
|
|
pull_number: pullNumber,
|
|
}
|
|
|
|
let isStale = false
|
|
let isSpammy = false
|
|
try {
|
|
const { data: pullRequest } = await octokit.pulls.get(params)
|
|
|
|
if (pullRequest && pullRequest.state === 'closed') {
|
|
const isMerged = pullRequest.merged === true
|
|
const closureAge = Date.now() - Date.parse(pullRequest.closed_at)
|
|
isStale = isMerged || closureAge >= prClosureStaleTime
|
|
|
|
if (isStale) {
|
|
console.debug(chalk.green(`STALE: ${displayParams(params)} is closed`))
|
|
} else {
|
|
console.debug(
|
|
chalk.blue(`NOT STALE: ${displayParams(params)} is closed but not yet stale`)
|
|
)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Using a standard GitHub PAT, PRs from spammy users will respond as 404
|
|
if (error.status === 404) {
|
|
isStale = true
|
|
isSpammy = true
|
|
console.debug(chalk.yellow(`STALE: ${displayParams(params)} is spammy or deleted`))
|
|
} else {
|
|
console.debug(chalk.red(`ERROR: ${displayParams(params)} - ${error.message}`))
|
|
}
|
|
}
|
|
|
|
return { isStale, isSpammy }
|
|
}
|
|
|
|
async function deleteEnvironment(envName) {
|
|
try {
|
|
let deploymentCount = 0
|
|
|
|
// Get all of the Deployments to signal this environment's complete deactivation
|
|
for await (const response of octokit.paginate.iterator(octokit.repos.listDeployments, {
|
|
owner,
|
|
repo,
|
|
|
|
// In the GitHub API, there can only be one active deployment per environment.
|
|
// For our many staging apps, we must use the unique appName as the environment.
|
|
environment: envName,
|
|
})) {
|
|
const { data: deployments } = response
|
|
|
|
// Deactivate ALL of the deployments
|
|
for (const deployment of deployments) {
|
|
// Deactivate this Deployment with an 'inactive' DeploymentStatus
|
|
await octokit.repos.createDeploymentStatus({
|
|
owner,
|
|
repo,
|
|
deployment_id: deployment.id,
|
|
state: 'inactive',
|
|
description: 'The app was undeployed',
|
|
log_url: logUrl,
|
|
// The 'ant-man' preview is required for `state` values of 'inactive', as well as
|
|
// the use of the `log_url`, `environment_url`, and `auto_inactive` parameters.
|
|
// The 'flash' preview is required for `state` values of 'in_progress' and 'queued'.
|
|
mediaType: {
|
|
previews: ['ant-man', 'flash'],
|
|
},
|
|
})
|
|
|
|
// Delete this Deployment
|
|
await octokit.repos.deleteDeployment({
|
|
owner,
|
|
repo,
|
|
deployment_id: deployment.id,
|
|
})
|
|
|
|
deploymentCount++
|
|
}
|
|
}
|
|
|
|
// Delete this Environment
|
|
try {
|
|
await octokit.repos.deleteAnEnvironment({
|
|
// Must use a PAT with more elevated permissions than GITHUB_TOKEN can achieve!
|
|
headers: {
|
|
authorization: `token ${process.env.ELEVATED_TOKEN}`,
|
|
},
|
|
owner,
|
|
repo,
|
|
environment_name: envName,
|
|
})
|
|
} catch (error) {
|
|
if (error.status !== 404) {
|
|
throw error
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
'✅',
|
|
chalk.green(
|
|
`Removed stale deployment environment "${envName}" (${deploymentCount} deployments)`
|
|
)
|
|
)
|
|
} catch (error) {
|
|
console.log(
|
|
'❌',
|
|
chalk.red(
|
|
`ERROR: Failed to remove stale deployment environment "${envName}" - ${error.message}`
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|