diff --git a/.github/workflows/orphaned-features-check.yml b/.github/workflows/orphaned-features-check.yml new file mode 100644 index 000000000000..b3254e06ce9e --- /dev/null +++ b/.github/workflows/orphaned-features-check.yml @@ -0,0 +1,109 @@ +name: 'Orphaned features check' + +# **What it does**: Finds any data/features that are no longer used in the repo. +# **Why we have it**: To avoid orphans into the repo. +# **Who does it impact**: Docs content. + +on: + workflow_dispatch: + schedule: + - cron: '20 16 * * 1' # Run every Monday at 16:20 UTC / 8:20 PST + pull_request: + paths: + - .github/workflows/orphaned-features-check.yml + # In case any of the dependencies affect the script + - 'package*.json' + - 'src/data-directory/scripts/find-orphaned-features/**' + - .github/actions/clone-translations/action.yml + - .github/actions/node-npm-setup/action.yml + +permissions: + contents: read + +jobs: + orphaned-features-check: + if: ${{ github.repository == 'github/docs-internal' }} + runs-on: ubuntu-latest + steps: + - name: Checkout English repo + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + # Using a PAT is necessary so that the new commit will trigger the + # CI in the PR. (Events from GITHUB_TOKEN don't trigger new workflows.) + token: ${{ secrets.DOCS_BOT_PAT_READPUBLICKEY }} + + # It's important because translations are often a bit behind. + # So if a translation is a bit behind, it might still be referencing + # a feature even though none of the English content does. + - name: Clone all translations + uses: ./.github/actions/clone-translations + with: + token: ${{ secrets.DOCS_BOT_PAT_READPUBLICKEY }} + + - uses: ./.github/actions/node-npm-setup + + - name: Check for orphaned features + env: + # Needed for gh + GITHUB_TOKEN: ${{ secrets.DOCS_BOT_PAT_READPUBLICKEY }} + DRY_RUN: ${{ github.event_name == 'pull_request'}} + run: | + set -e + + npm run find-orphaned-features -- find --verbose --output /tmp/orphaned-features.json + + if [ -f /tmp/orphaned-features.json ]; then + echo "Orphaned features found:" + cat /tmp/orphaned-features.json + else + echo "No orphaned features found" + exit 0 + fi + + # Why only 5? + # Because, we're not in a hurry and anything larger than that would + # make the PR too intimidatingly big to review. + npm run find-orphaned-features -- delete --max 5 --verbose /tmp/orphaned-features.json + + git status + + # When run on a pull_request, we're just testing the tooling. + # Exit before it actually pushes the possible changes. + if [ "$DRY_RUN" = "true" ]; then + echo "Dry-run mode when run in a pull request" + exit 0 + fi + + # Replicated from the translation pipeline PR-maker Action + git config --global user.name "docs-bot" + git config --global user.email "77750099+docs-bot@users.noreply.github.com" + + date=$(date '+%Y-%m-%d-%H-%M') + branchname=orphaned-features-$date-$GITHUB_RUN_ID + + git checkout -b $branchname + git commit -a -m "Delete orphaned features $date" + git push origin $branchname + + body=$(cat <<-EOM + Found with the 'npm run find-orphaned-features' script. + The orphaned features workflow file .github/workflows/orphaned-features-check.yml + runs every Monday at 16:20 UTC / 8:20 PST. + The first responder should just spot-check some of the orphans + to make sure they aren't referenced anywhere + and then approve and merge the pull request. + For more information, see [Doc: Orphaned Features](https://github.com/github/docs-engineering/blob/main/docs/orphaned-features.md). + EOM + ) + + gh pr create \ + --title "Delete orphaned features ($date)" \ + --body "$body" \ + --repo github/docs-internal \ + --label docs-content-fr + + - uses: ./.github/actions/slack-alert + if: ${{ failure() && github.event_name == 'schedule' }} + with: + slack_channel_id: ${{ secrets.DOCS_ALERTS_SLACK_CHANNEL_ID }} + slack_token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }} diff --git a/src/data-directory/scripts/find-orphaned-features/delete.ts b/src/data-directory/scripts/find-orphaned-features/delete.ts new file mode 100644 index 000000000000..2d4b783f1dc8 --- /dev/null +++ b/src/data-directory/scripts/find-orphaned-features/delete.ts @@ -0,0 +1,42 @@ +import fs from 'fs' +import path from 'path' + +import chalk from 'chalk' +import languages from '@/languages/lib/languages.js' + +type Options = { + verbose?: boolean + max: number +} + +export async function deleteOrphans(filePath: string, options: Options) { + const orphans = JSON.parse(fs.readFileSync(filePath, 'utf8')) + if (!Array.isArray(orphans)) { + throw new Error(`Expected an array of orphans in ${filePath}`) + } + let count = 0 + if (options.verbose) { + console.log(chalk.yellow(`${orphans.length} orphans found in ${filePath}`)) + if (orphans.length > options.max) { + console.log(chalk.yellow(`Only deleting the first ${options.max} orphans`)) + } + } + let countDeletions = 0 + for (const orphan of orphans.slice(0, options.max)) { + count++ + const absolutePath = path.join(languages.en.dir, orphan) + if (!fs.existsSync(absolutePath)) { + throw new Error(`File does not exist: ${absolutePath} (number ${count} in ${filePath})`) + } + if (options.verbose) { + console.log(chalk.green(`Deleting ${absolutePath}`)) + } + fs.unlinkSync(absolutePath) + countDeletions++ + } + if (countDeletions > 0) { + console.log(chalk.green(`Deleted ${countDeletions} orphans`)) + } else { + console.log(chalk.yellow(`Deleted no orphans`)) + } +} diff --git a/src/data-directory/scripts/find-orphaned-features/index.ts b/src/data-directory/scripts/find-orphaned-features/index.ts index b4ec025170ca..4a1ecc7189c3 100644 --- a/src/data-directory/scripts/find-orphaned-features/index.ts +++ b/src/data-directory/scripts/find-orphaned-features/index.ts @@ -1,5 +1,6 @@ import { program } from 'commander' import { find } from './find' +import { deleteOrphans } from './delete' program .name('find-orphaned-features') @@ -15,4 +16,12 @@ program .option('-v, --verbose', 'Verbose') .action(find) +program + .command('delete') + .description('Delete features based on found orphans') + .option('-m, --max ', 'Maximum number of files to delete', (val) => parseInt(val), 10) + .option('-v, --verbose', 'Verbose') + .argument('', 'path to the JSON file') + .action(deleteOrphans) + program.parse(process.argv)