diff --git a/.github/workflows/pat-cleanup.yml b/.github/workflows/pat-cleanup.yml new file mode 100644 index 000000000..6f86b5e4e --- /dev/null +++ b/.github/workflows/pat-cleanup.yml @@ -0,0 +1,45 @@ +# Delete expired classic PATs from the e2e test account (botsend). +# Each e2e run creates a PAT and tries to clean it up, but crashed or +# timed-out runs leave orphaned tokens that accumulate over time. + +name: Clean up expired PATs + +on: + schedule: + - cron: "0 4 * * 0" # Weekly on Sundays at 4am UTC + workflow_dispatch: + +concurrency: + group: pat-cleanup + cancel-in-progress: false + +permissions: + contents: read + +jobs: + cleanup: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install Playwright browser and system dependencies + run: | + go run github.com/playwright-community/playwright-go/cmd/playwright install chromium + npx playwright install-deps chromium + + - name: Decode session + run: | + SESSION_FILE="${RUNNER_TEMP}/github-session.json" + printf '%s' "$E2E_GITHUB_SESSION_B64" | base64 -d > "$SESSION_FILE" + chmod 600 "$SESSION_FILE" + echo "E2E_GITHUB_SESSION_FILE=${SESSION_FILE}" >> "$GITHUB_ENV" + env: + E2E_GITHUB_SESSION_B64: ${{ secrets.E2E_GITHUB_SESSION }} + + - name: Delete expired PATs + run: ./hack/cleanup-pats diff --git a/hack/cleanup-pats b/hack/cleanup-pats new file mode 100755 index 000000000..647db818d --- /dev/null +++ b/hack/cleanup-pats @@ -0,0 +1,25 @@ +#!/bin/bash + +# cleanup-pats - Delete all expired classic PATs from the e2e test account +# +# Uses Playwright browser automation to navigate the GitHub tokens page +# and delete expired tokens. Reports how many unexpired tokens remain. +# +# Requires: E2E_GITHUB_SESSION_FILE (Playwright storageState JSON) +# +# To generate a session file first: +# make e2e-export-session +# +# Usage: cleanup-pats + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +if [[ -z "${E2E_GITHUB_SESSION_FILE:-}" ]]; then + echo "E2E_GITHUB_SESSION_FILE is not set." + echo "Generate one with: make e2e-export-session" + exit 1 +fi + +exec go run "${REPO_ROOT}/hack/cleanup-pats.go" diff --git a/hack/cleanup-pats.go b/hack/cleanup-pats.go new file mode 100644 index 000000000..14bfe9a8f --- /dev/null +++ b/hack/cleanup-pats.go @@ -0,0 +1,211 @@ +// cleanup-pats navigates to the GitHub classic PAT settings page via Playwright +// and deletes all expired tokens. Iterates through paginated token pages from +// last to first, deleting expired tokens on each page. Reports how many +// unexpired tokens remain and prints the URL for manual review. +// +// This is a helper for hack/cleanup-pats and is not intended to be run directly. +// +//go:build ignore + +package main + +import ( + "fmt" + "log" + "os" + "strconv" + "strings" + + "github.com/playwright-community/playwright-go" +) + +const tokensURL = "https://github.com/settings/tokens" + +func main() { + sessionFile := os.Getenv("E2E_GITHUB_SESSION_FILE") + if sessionFile == "" { + log.Fatal("Set E2E_GITHUB_SESSION_FILE to a Playwright storageState JSON file") + } + if _, err := os.Stat(sessionFile); err != nil { + log.Fatalf("E2E_GITHUB_SESSION_FILE %q does not exist: %v", sessionFile, err) + } + + pw, err := playwright.Run() + if err != nil { + log.Fatalf("starting playwright: %v", err) + } + defer pw.Stop() + + browser, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{ + Headless: playwright.Bool(true), + }) + if err != nil { + log.Fatalf("launching browser: %v", err) + } + defer browser.Close() + + ctx, err := browser.NewContext(playwright.BrowserNewContextOptions{ + StorageStatePath: playwright.String(sessionFile), + }) + if err != nil { + log.Fatalf("creating context: %v", err) + } + + page, err := ctx.NewPage() + if err != nil { + log.Fatalf("creating page: %v", err) + } + + // Load first page to get total page count. + if _, err := page.Goto(tokensURL, playwright.PageGotoOptions{ + WaitUntil: playwright.WaitUntilStateDomcontentloaded, + Timeout: playwright.Float(15000), + }); err != nil { + log.Fatalf("navigating to tokens page: %v", err) + } + + if strings.Contains(page.URL(), "/login") { + log.Fatalf("session is not authenticated — redirected to %s", page.URL()) + } + + totalPages := 1 + currentEl := page.Locator(".current[data-total-pages]") + if c, _ := currentEl.Count(); c > 0 { + if tp, err := currentEl.GetAttribute("data-total-pages"); err == nil { + if n, err := strconv.Atoi(tp); err == nil { + totalPages = n + } + } + } + fmt.Printf("Token pages: %d\n", totalPages) + + deleted := 0 + // Work backwards from the last page where expired tokens live. + for pg := totalPages; pg >= 1; pg-- { + pageURL := fmt.Sprintf("%s?page=%d", tokensURL, pg) + if _, err := page.Goto(pageURL, playwright.PageGotoOptions{ + WaitUntil: playwright.WaitUntilStateDomcontentloaded, + Timeout: playwright.Float(15000), + }); err != nil { + log.Printf("could not load page %d: %v, stopping", pg, err) + break + } + + pageDeleted := deleteExpiredOnPage(page, pg) + deleted += pageDeleted + + if pageDeleted == 0 { + fmt.Printf("Page %d: no expired tokens.\n", pg) + } + } + + // Navigate back to page 1 to count active tokens. + if _, err := page.Goto(tokensURL, playwright.PageGotoOptions{ + WaitUntil: playwright.WaitUntilStateDomcontentloaded, + Timeout: playwright.Float(15000), + }); err != nil { + log.Printf("could not reload first page: %v", err) + } + + // Re-read total pages for the remaining count. + remainingPages := 1 + currentEl = page.Locator(".current[data-total-pages]") + if c, _ := currentEl.Count(); c > 0 { + if tp, err := currentEl.GetAttribute("data-total-pages"); err == nil { + if n, err := strconv.Atoi(tp); err == nil { + remainingPages = n + } + } + } + firstPageCount, _ := page.Locator(".access-token").Count() + + // Estimate: full pages have 10 tokens, last page has firstPageCount. + var remaining int + if remainingPages == 1 { + remaining = firstPageCount + } else { + remaining = (remainingPages-1)*10 + firstPageCount + } + + fmt.Println() + fmt.Printf("Deleted: %d expired PATs\n", deleted) + fmt.Printf("Remaining: ~%d unexpired PATs\n", remaining) + fmt.Printf("\nReview remaining tokens at: %s\n", tokensURL) +} + +// deleteExpiredOnPage deletes all expired tokens visible on the current page. +// Returns the number deleted. +func deleteExpiredOnPage(page playwright.Page, pg int) int { + const maxDeletes = 100 // guard against infinite loops + deleted := 0 + for deleted < maxDeletes { + // Find tokens with "Expired on" text on this page. + expiredRows := page.Locator(".access-token:has-text('Expired on')") + count, err := expiredRows.Count() + if err != nil || count == 0 { + break + } + + row := expiredRows.First() + text, _ := row.InnerText() + // Extract the token name from the text (format: "Delete\n...\nname — scopes\nExpired on ..."). + name := extractTokenName(text) + + // Extract the form action URL and CSRF token, then POST + // directly via page.Evaluate+fetch. This avoids fighting with + // Playwright's navigation handling around form.submit(). + formAction, _ := row.Locator("form.js-revoke-access-form").GetAttribute("action") + csrfToken, _ := row.Locator("form.js-revoke-access-form input[name='authenticity_token']").GetAttribute("value") + if formAction == "" || csrfToken == "" { + log.Printf("page %d: missing form action or CSRF token for %q, stopping", pg, name) + break + } + + // POST the delete and check the response status. + js := fmt.Sprintf(`async () => { + const resp = await fetch(%q, { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: '_method=delete&authenticity_token=' + encodeURIComponent(%q), + }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + }`, formAction, csrfToken) + if _, err := page.Evaluate(js); err != nil { + log.Printf("page %d: delete fetch failed for %q: %v, stopping", pg, name, err) + break + } + + // Reload the page to see updated token list. + if _, err := page.Reload(playwright.PageReloadOptions{ + WaitUntil: playwright.WaitUntilStateDomcontentloaded, + Timeout: playwright.Float(10000), + }); err != nil { + log.Printf("page %d: reload after deleting %q failed: %v, stopping", pg, name, err) + deleted++ + break + } + + deleted++ + fmt.Printf(" Page %d: deleted %s\n", pg, name) + } + + if deleted > 0 { + fmt.Printf("Page %d: deleted %d expired PATs\n", pg, deleted) + } + return deleted +} + +// extractTokenName pulls the token note from the row's inner text. +// The text format is "Delete\n...\nNAME — scopes\nExpired on ...". +func extractTokenName(text string) string { + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if strings.Contains(line, "fullsend-e2e-") || strings.Contains(line, " — ") { + if idx := strings.Index(line, " — "); idx > 0 { + return line[:idx] + } + return line + } + } + return "(unknown)" +}