diff --git a/.github/scripts/pr-preview/cleanup-github-preview-env.cjs b/.github/scripts/pr-preview/cleanup-github-preview-env.cjs index df5b07d..bcad34b 100644 --- a/.github/scripts/pr-preview/cleanup-github-preview-env.cjs +++ b/.github/scripts/pr-preview/cleanup-github-preview-env.cjs @@ -2,8 +2,14 @@ /** * Best-effort teardown of GitHub Deployment metadata for a closed PR preview. - * Marks all deployments for `preview-pr-` inactive, then deletes the Environment - * when the token allows (soft-fail on permission errors). + * + * Marks matching deployments inactive. Deletes legacy `preview-pr-` Environments only + * when the token allows — default `GITHUB_TOKEN` cannot delete Environments + * ("Resource not accessible by integration"). New previews use the shared `preview` + * Environment (see pr-deploy.yml) so nothing needs deleting after inactivation. + * + * Optional secret `PREVIEW_ENV_ADMIN_TOKEN` (classic PAT with `repo`, or App token with + * Environments write) enables legacy environment deletion. * * @param {{ * github: { @@ -20,6 +26,64 @@ * core: { info: Function, warning: Function, setOutput: Function, setFailed: Function }, * }} ctx */ + +/** Shared GitHub Environment name for all PR preview Deployments (GITHUB_TOKEN-safe). */ +const SHARED_PREVIEW_ENVIRONMENT = "preview"; + +function legacyPreviewEnvironmentName(prNumber) { + return `preview-pr-${prNumber}`; +} + +function parsePayload(payload) { + if (payload == null) { + return null; + } + if (typeof payload === "object") { + return payload; + } + if (typeof payload === "string") { + try { + return JSON.parse(payload); + } catch { + return null; + } + } + return null; +} + +/** Whether a deployment row belongs to this PR preview. */ +function deploymentMatchesPr(deployment, prNumber) { + const n = Number(prNumber); + if (!Number.isInteger(n) || n <= 0) { + return false; + } + if (deployment?.environment === legacyPreviewEnvironmentName(n)) { + return true; + } + const payload = parsePayload(deployment?.payload); + if (payload != null && Number(payload.pr_number) === n) { + return true; + } + const description = deployment?.description; + if (typeof description === "string") { + if (description === `PR ${n} preview` || description.startsWith(`PR ${n} `)) { + return true; + } + } + return false; +} + +function isEnvironmentDeletePermissionError(error) { + const msg = String(error); + const lower = msg.toLowerCase(); + return ( + lower.includes("resource not accessible by integration") || + lower.includes("httperror: resource not accessible") || + /\b403\b/.test(msg) || + lower.includes("forbidden") + ); +} + module.exports = async function cleanupGithubPreviewEnv(ctx) { const { github, context, core } = ctx; const owner = context.repo.owner; @@ -31,37 +95,50 @@ module.exports = async function cleanupGithubPreviewEnv(ctx) { return; } - const environment = `preview-pr-${prNumber}`; + const legacyEnvironment = legacyPreviewEnvironmentName(prNumber); let inactivated = 0; let statusErrors = 0; + const environmentNames = [legacyEnvironment, SHARED_PREVIEW_ENVIRONMENT]; + const seenDeploymentIds = new Set(); + try { - const deployments = await github.paginate(github.rest.repos.listDeployments, { - owner, - repo, - environment, - per_page: 100, - }); - core.info(`cleanup-github-preview-env: ${deployments.length} deployment(s) for ${environment}`); - for (const deployment of deployments) { - const id = deployment?.id; - if (id == null) { - continue; - } - try { - await github.rest.repos.createDeploymentStatus({ - owner, - repo, - deployment_id: id, - state: "inactive", - description: "Preview stack destroyed", - }); - inactivated += 1; - } catch (error) { - statusErrors += 1; - core.warning( - `cleanup-github-preview-env: inactive status failed for deployment ${id}: ${String(error)}`, - ); + for (const environment of environmentNames) { + const deployments = await github.paginate(github.rest.repos.listDeployments, { + owner, + repo, + environment, + per_page: 100, + }); + const matching = deployments.filter((d) => { + const id = d?.id; + if (id == null || seenDeploymentIds.has(String(id))) { + return false; + } + return deploymentMatchesPr(d, prNumber); + }); + core.info( + `cleanup-github-preview-env: ${matching.length}/${deployments.length} deployment(s) for ${environment} match PR ${prNumber}`, + ); + for (const deployment of matching) { + const id = deployment.id; + seenDeploymentIds.add(String(id)); + try { + await github.rest.repos.createDeploymentStatus({ + owner, + repo, + deployment_id: id, + state: "inactive", + description: "Preview stack destroyed", + auto_inactive: false, + }); + inactivated += 1; + } catch (error) { + statusErrors += 1; + core.warning( + `cleanup-github-preview-env: inactive status failed for deployment ${id}: ${String(error)}`, + ); + } } } } catch (error) { @@ -74,23 +151,31 @@ module.exports = async function cleanupGithubPreviewEnv(ctx) { } let environmentDeleted = false; + let environmentDeleteSkippedExpected = false; try { await github.rest.repos.deleteAnEnvironment({ owner, repo, - environment_name: environment, + environment_name: legacyEnvironment, }); environmentDeleted = true; - core.info(`cleanup-github-preview-env: deleted environment ${environment}`); + core.info(`cleanup-github-preview-env: deleted legacy environment ${legacyEnvironment}`); } catch (error) { const msg = String(error); - // Soft-fail: GITHUB_TOKEN often lacks admin to delete Environments. if (msg.includes("404") || msg.toLowerCase().includes("not found")) { - core.info(`cleanup-github-preview-env: environment ${environment} already absent`); + core.info( + `cleanup-github-preview-env: legacy environment ${legacyEnvironment} already absent (ok)`, + ); environmentDeleted = true; + } else if (isEnvironmentDeletePermissionError(error)) { + // Expected with default GITHUB_TOKEN. Shared `preview` env is never deleted. + environmentDeleteSkippedExpected = true; + core.info( + `cleanup-github-preview-env: left ${legacyEnvironment} in place — default GITHUB_TOKEN cannot delete Environments. Deployments inactivated=${inactivated}. Optional: set secret PREVIEW_ENV_ADMIN_TOKEN (repo-scoped PAT) or delete leftover preview-pr-* via \`bun run preview:cleanup:orphans -- --apply\` / repo Settings → Environments.`, + ); } else { core.warning( - `cleanup-github-preview-env: could not delete environment ${environment} (soft-fail): ${msg}`, + `cleanup-github-preview-env: could not delete environment ${legacyEnvironment}: ${msg}`, ); } } @@ -99,9 +184,17 @@ module.exports = async function cleanupGithubPreviewEnv(ctx) { core.setOutput("outcome", outcome); core.setOutput("inactivated", String(inactivated)); core.setOutput("environment_deleted", environmentDeleted ? "true" : "false"); + core.setOutput( + "environment_delete_skipped_expected", + environmentDeleteSkippedExpected ? "true" : "false", + ); if (outcome === "failure") { core.setFailed( - `cleanup-github-preview-env: ${statusErrors} deployment status error(s) for ${environment}`, + `cleanup-github-preview-env: ${statusErrors} deployment status error(s) for PR ${prNumber}`, ); } }; + +module.exports.SHARED_PREVIEW_ENVIRONMENT = SHARED_PREVIEW_ENVIRONMENT; +module.exports.deploymentMatchesPr = deploymentMatchesPr; +module.exports.isEnvironmentDeletePermissionError = isEnvironmentDeletePermissionError; diff --git a/.github/workflows/pr-deploy.yml b/.github/workflows/pr-deploy.yml index f0fe6b9..09945d6 100644 --- a/.github/workflows/pr-deploy.yml +++ b/.github/workflows/pr-deploy.yml @@ -2,7 +2,9 @@ # **Fork** PRs run **Quality only** (after GitHub’s fork workflow approval gate); they do **not** deploy previews or receive deploy secrets. # # **Destroy** on **`pull_request` `closed`** for **same-repo** PRs only (immediate teardown). Uses **`verify:deploy-env:preview`** before destroy so teardown is not skipped when **`DEPLOY_ENABLED`** is false (avoids orphaned **`pr-*`** stacks). -# After a successful destroy, best-effort deactivates/deletes GitHub **`preview-pr-`** Deployments/Environment. +# After a successful destroy, best-effort deactivates GitHub Deployments for this PR +# (shared Environment **`preview`**, plus legacy **`preview-pr-`**). Default **`GITHUB_TOKEN`** +# cannot delete Environments; optional secret **`PREVIEW_ENV_ADMIN_TOKEN`** enables legacy env delete. # Operators can sweep leftover exact Alchemy **`…-pr-*`** stacks (see **`preview-pr-resources`**) with **`bun run preview:cleanup:orphans`** (dry-run default; **`--apply`** to mutate; open PRs auto-excluded; **`--include-open`** to override; **`--exclude `** for extras). # # **`alchemy destroy`** still evaluates each **`alchemy.run.ts`** — keep **`Destroy PR preview`** **`env:`** in sync with **PR preview deploy** when you add bindings / env reads. @@ -128,6 +130,8 @@ jobs: bun run destroy:preview 2>&1 | tee destroy-output.txt # Only after destroy succeeds — otherwise GitHub would hide a still-live CF preview. + # Optional PREVIEW_ENV_ADMIN_TOKEN (classic PAT with `repo`) can delete leftover + # `preview-pr-*` Environments; default GITHUB_TOKEN cannot. - name: Clean up GitHub preview-pr environment id: gh_env_cleanup if: steps.destroy.outcome == 'success' @@ -135,6 +139,7 @@ jobs: env: PR_NUMBER: ${{ github.event.pull_request.number }} with: + github-token: ${{ secrets.PREVIEW_ENV_ADMIN_TOKEN || github.token }} script: | await require('${{ github.workspace }}/.github/scripts/pr-preview/cleanup-github-preview-env.cjs')({ github, context, core }); @@ -149,6 +154,7 @@ jobs: # Prefer helper `outputs.outcome` (setFailed still sets outputs); fall back to step outcome when skipped. GH_OUT: ${{ steps.gh_env_cleanup.outputs.outcome || steps.gh_env_cleanup.outcome }} GH_ENV_DELETED: ${{ steps.gh_env_cleanup.outputs.environment_deleted }} + GH_ENV_DELETE_SKIPPED: ${{ steps.gh_env_cleanup.outputs.environment_delete_skipped_expected }} GH_INACTIVATED: ${{ steps.gh_env_cleanup.outputs.inactivated }} run: | merged_note="closed without merge" @@ -174,7 +180,13 @@ jobs: fi echo "" if [[ "${GH_OUT}" == "success" ]]; then - echo "**GitHub:** \`preview-pr-${PR_NUMBER}\` — inactivated ${GH_INACTIVATED:-0} deployment(s); environment deleted=${GH_ENV_DELETED:-unknown}." + if [[ "${GH_ENV_DELETED}" == "true" ]]; then + echo "**GitHub:** inactivated ${GH_INACTIVATED:-0} deployment(s); legacy \`preview-pr-${PR_NUMBER}\` environment deleted." + elif [[ "${GH_ENV_DELETE_SKIPPED}" == "true" ]]; then + echo "**GitHub:** inactivated ${GH_INACTIVATED:-0} deployment(s). Legacy environment not deleted (expected with default \`GITHUB_TOKEN\`; optional secret \`PREVIEW_ENV_ADMIN_TOKEN\` or orphan sweeper)." + else + echo "**GitHub:** inactivated ${GH_INACTIVATED:-0} deployment(s); environment deleted=${GH_ENV_DELETED:-unknown}." + fi elif [[ "${GH_OUT}" == "failure" ]]; then echo "**GitHub:** preview environment cleanup failed — see **Clean up GitHub** logs." elif [[ "${GH_OUT}" == "skipped" ]]; then @@ -192,6 +204,7 @@ jobs: DS_OUT: ${{ steps.destroy.outcome }} GH_OUT: ${{ steps.gh_env_cleanup.outputs.outcome || steps.gh_env_cleanup.outcome }} GH_ENV_DELETED: ${{ steps.gh_env_cleanup.outputs.environment_deleted }} + GH_ENV_DELETE_SKIPPED: ${{ steps.gh_env_cleanup.outputs.environment_delete_skipped_expected }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} with: script: | @@ -203,6 +216,7 @@ jobs: const ds = process.env.DS_OUT ?? ""; const gh = process.env.GH_OUT ?? ""; const ghEnvDeleted = process.env.GH_ENV_DELETED ?? ""; + const ghEnvDeleteSkipped = process.env.GH_ENV_DELETE_SKIPPED ?? ""; const runUrl = process.env.RUN_URL ?? ""; const marker = ""; @@ -223,13 +237,11 @@ jobs: const ghLine = gh === "success" - ? `- GitHub \`preview-pr-${prNumber}\` cleaned up${ - ghEnvDeleted === "true" - ? " (environment deleted)" - : ghEnvDeleted === "false" - ? " (deployments inactivated; environment delete soft-failed)" - : "" - }` + ? ghEnvDeleted === "true" + ? `- GitHub deployments inactivated; legacy \`preview-pr-${prNumber}\` environment deleted` + : ghEnvDeleteSkipped === "true" + ? `- GitHub deployments inactivated (legacy \`preview-pr-${prNumber}\` Environment left — \`GITHUB_TOKEN\` cannot delete Environments; optional \`PREVIEW_ENV_ADMIN_TOKEN\` or orphan sweeper)` + : `- GitHub preview deployments inactivated` : gh === "failure" ? `- GitHub preview environment cleanup failed — see [workflow run](${runUrl})` : gh === "skipped" @@ -557,13 +569,14 @@ jobs: owner, repo, ref: headSha, - environment: `preview-pr-${prNumber}`, + // Shared Environment — GITHUB_TOKEN cannot delete per-PR Environments. + environment: "preview", description: `PR ${prNumber} preview`, auto_merge: false, required_contexts: [], transient_environment: true, production_environment: false, - payload: JSON.stringify({ pr_number: Number(prNumber) }), + payload: { pr_number: Number(prNumber) }, }); if (!deployment?.id) { core.setOutput("deployment_id", ""); diff --git a/docs/github-admin.md b/docs/github-admin.md index 76f9f28..07ef1bd 100644 --- a/docs/github-admin.md +++ b/docs/github-admin.md @@ -18,7 +18,7 @@ Use this page when you want to change how GitHub behaves after onboarding. - **Quality reusable** (`.github/workflows/quality-reusable.yml`): parallel jobs — Drizzle generated-artifact guard, lint, **`typegen`** + typecheck, **`bun run setup -- --yes`** + build. Invoked by **`Main`** (push **`main`**) and **`PR preview`** (open/sync/reopen on **`main`**). - **Main** (`.github/workflows/main-push.yml`): **push** **`main`** → Quality → caller-level **Quality checks** gate → **staging** deploy (`maybe_production_pr` when **`AUTO_PRODUCTION_PR`**). Uses Environment **`staging`**. - **Production deploy** (`.github/workflows/prod-deploy.yml`): **push** **`production`** or **`workflow_dispatch`** when the selected ref is **`production`**. -- **PR previews** (`.github/workflows/pr-deploy.yml`): **same-repo** PRs run Quality in this workflow, then preview deploy to **`staging`** when **`DEPLOY_ENABLED`**; sticky PR comments and optional **`preview-pr-`** GitHub Deployments. **Fork PRs** run **Quality only** (no preview deploy, no **`staging-fork`** preview path). Preview stacks use **`STAGE=pr-`**. **Teardown** on **`pull_request` `closed`** (same-repo only): **`verify:deploy-env:preview`**, then **`destroy:preview`**, and on destroy success best-effort GitHub **`preview-pr-`** deployment inactivation / Environment delete; checks out the **base** branch so destroy does not run untrusted PR scripts. Alchemy state for all non-local stages lives in the shared account **`alchemy-state-service`** Worker — teardown does **not** delete it. Operators can sweep leftover **exact** Alchemy preview resources (see **`alchemy-utils/preview-pr-resources`** catalogs; keep **`bun run check:preview-catalog`** green when adding resources) and stale **`preview-pr-*`** Environments with **`bun run preview:cleanup:orphans`** (dry-run by default; add **`--apply`** to delete; open PRs are auto-excluded unless **`--include-open`**; incomplete CF/GitHub inventory aborts **`--apply`**). **`alchemy destroy`** still loads **`alchemy.run.ts`** top-level **`requireEnv`** — mirror deploy **`env`** keys on **Destroy PR preview** when you add vars at module scope (see [**multiworker-gotchas** §22](../.agents/skills/multiworker-gotchas/SKILL.md)). +- **PR previews** (`.github/workflows/pr-deploy.yml`): **same-repo** PRs run Quality in this workflow, then preview deploy to **`staging`** when **`DEPLOY_ENABLED`**; sticky PR comments and optional GitHub Deployments under the shared Environment **`preview`** (payload/`description` carry the PR number). **Fork PRs** run **Quality only** (no preview deploy, no **`staging-fork`** preview path). Preview stacks use **`STAGE=pr-`**. **Teardown** on **`pull_request` `closed`** (same-repo only): **`verify:deploy-env:preview`**, then **`destroy:preview`**, and on destroy success inactivates that PR’s GitHub Deployments. Default **`GITHUB_TOKEN` cannot delete Environments** (GitHub returns **Resource not accessible by integration**); new previews therefore use the shared **`preview`** Environment instead of per-PR **`preview-pr-`**. Optional repository secret **`PREVIEW_ENV_ADMIN_TOKEN`** (classic PAT with **`repo`**, or a GitHub App token with Environments write) enables deleting leftover legacy **`preview-pr-*`** Environments during teardown. Checks out the **base** branch so destroy does not run untrusted PR scripts. Alchemy state for all non-local stages lives in the shared account **`alchemy-state-service`** Worker — teardown does **not** delete it. Operators can sweep leftover **exact** Alchemy preview resources (see **`alchemy-utils/preview-pr-resources`** catalogs; keep **`bun run check:preview-catalog`** green when adding resources) and stale **`preview-pr-*`** Environments with **`bun run preview:cleanup:orphans`** (dry-run by default; add **`--apply`** to delete; open PRs are auto-excluded unless **`--include-open`**; incomplete CF/GitHub inventory aborts **`--apply`**; uses your **`gh`** credentials, which can delete Environments). **`alchemy destroy`** still loads **`alchemy.run.ts`** top-level **`requireEnv`** — mirror deploy **`env`** keys on **Destroy PR preview** when you add vars at module scope (see [**multiworker-gotchas** §22](../.agents/skills/multiworker-gotchas/SKILL.md)). - **Branch protection:** the **`main`** ruleset requires the caller-level **Quality checks** gate, not an inner job from the reusable workflow. Default required context: **`Quality checks`** in [`github-policy-config.ts`](../packages/alchemy-utils/src/github-policy-config.ts). GitHub's PR UI may display this as **`PR preview / Quality checks (pull_request)`**, but the ruleset picker stores the unprefixed check context. Keep this as a normal job in [`pr-deploy.yml`](../.github/workflows/pr-deploy.yml); requiring **`PR preview / Quality / Quality checks`** (inside **`quality-reusable.yml`**) can stay stuck as **Expected — Waiting for status** even when the check run is green. ### `ALCHEMY_STATE_TOKEN` (GitHub Environments) @@ -34,6 +34,26 @@ After changing dotfiles, run **`bun run github:sync:staging`** / **`bun run gith Mismatch → **`[CloudflareStateStore] The token is invalid`**. Detail: [**workers-env-local §3**](../.agents/skills/workers-env-local/SKILL.md). +### Cleaning leftover PR previews + +**Proper ongoing fix** (merge [PR #33](https://github.com/firtoz/cf-multiworker-starter-kit/pull/33) / this branch): Deployments use shared Environment **`preview`**. Teardown inactivates that PR’s deployments with default **`GITHUB_TOKEN`**. No per-PR Environment is created, so nothing needs admin delete rights. + +**Optional auto-delete of legacy `preview-pr-*` shells:** create a classic PAT with **`repo`** scope, add repository secret **`PREVIEW_ENV_ADMIN_TOKEN`**, and teardown will delete leftover Environments. A GitHub App with **Environments: write** works the same way. + +**One-shot sweep of previous failures** (trusted machine, **`gh auth login`**): + +```bash +# 1) GitHub Environments only (safe when CF was already destroyed) +bun run preview:cleanup:orphans -- --github-only # dry-run +bun run preview:cleanup:orphans -- --github-only --apply # delete preview-pr-* envs + inactive deployments + +# 2) Full sweep (Cloudflare exact catalogs + GitHub) — needs CF account env loaded +bunx dotenv-cli -e account.env -e .env.staging -- bun run preview:cleanup:orphans +bunx dotenv-cli -e account.env -e .env.staging -- bun run preview:cleanup:orphans -- --apply +``` + +Open PRs are auto-excluded. The shared **`preview`** Environment is never deleted by the sweeper. + Use **`bun run github:setup`** for a step-by-step printout. Onboarding wrappers (trusted machine, **`gh`** authenticated): diff --git a/packages/scripts/src/cleanup-github-preview-env.test.ts b/packages/scripts/src/cleanup-github-preview-env.test.ts new file mode 100644 index 0000000..20dd9df --- /dev/null +++ b/packages/scripts/src/cleanup-github-preview-env.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import cleanup from "../../../.github/scripts/pr-preview/cleanup-github-preview-env.cjs"; + +const { deploymentMatchesPr, isEnvironmentDeletePermissionError, SHARED_PREVIEW_ENVIRONMENT } = + cleanup; + +describe("cleanup-github-preview-env helpers", () => { + test("shared preview environment name", () => { + expect(SHARED_PREVIEW_ENVIRONMENT).toBe("preview"); + }); + + test("deploymentMatchesPr by legacy environment name", () => { + expect(deploymentMatchesPr({ environment: "preview-pr-31", id: 1 }, 31)).toBe(true); + expect(deploymentMatchesPr({ environment: "preview-pr-30", id: 1 }, 31)).toBe(false); + }); + + test("deploymentMatchesPr by payload.pr_number", () => { + expect( + deploymentMatchesPr({ environment: "preview", payload: { pr_number: 31 }, id: 2 }, 31), + ).toBe(true); + expect( + deploymentMatchesPr( + { environment: "preview", payload: JSON.stringify({ pr_number: 31 }), id: 3 }, + 31, + ), + ).toBe(true); + expect( + deploymentMatchesPr({ environment: "preview", payload: { pr_number: 9 }, id: 4 }, 31), + ).toBe(false); + }); + + test("deploymentMatchesPr by description", () => { + expect( + deploymentMatchesPr({ environment: "preview", description: "PR 31 preview", id: 5 }, 31), + ).toBe(true); + }); + + test("isEnvironmentDeletePermissionError recognizes GITHUB_TOKEN denial", () => { + expect( + isEnvironmentDeletePermissionError( + new Error("HttpError: Resource not accessible by integration"), + ), + ).toBe(true); + expect(isEnvironmentDeletePermissionError(new Error("HttpError: Not Found - 404"))).toBe(false); + }); +}); diff --git a/packages/scripts/src/preview-cleanup-orphans.ts b/packages/scripts/src/preview-cleanup-orphans.ts index b1ae4a5..4eceb53 100644 --- a/packages/scripts/src/preview-cleanup-orphans.ts +++ b/packages/scripts/src/preview-cleanup-orphans.ts @@ -9,8 +9,10 @@ * bun run preview:cleanup:orphans -- --exclude 22 * bun run preview:cleanup:orphans -- --apply * bun run preview:cleanup:orphans -- --apply --include-open # dangerous: also target open PRs + * bun run preview:cleanup:orphans -- --github-only --apply # GH Environments only (no CF) * - * Needs: CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID, and `gh` auth for GitHub Environments. + * Needs: `gh` auth for GitHub Environments. Full sweep also needs CLOUDFLARE_API_TOKEN + + * CLOUDFLARE_ACCOUNT_ID (skipped with --github-only). */ import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; @@ -52,10 +54,11 @@ type ListResult = { function usage(): never { console.error( - "Usage: bun run preview:cleanup:orphans -- [--exclude [,n…]] [--include-open] [--apply]\n" + + "Usage: bun run preview:cleanup:orphans -- [--exclude [,n…]] [--include-open] [--github-only] [--apply]\n" + " Default is dry-run. Pass --apply to delete.\n" + " Open PRs are auto-excluded unless --include-open.\n" + - " --exclude adds extra PR numbers to keep (e.g. --exclude 22).", + " --exclude adds extra PR numbers to keep (e.g. --exclude 22).\n" + + " --github-only scans/deletes legacy preview-pr-* GitHub Environments only (no Cloudflare).", ); process.exit(2); } @@ -80,10 +83,12 @@ function parseArgs(argv: string[]): { exclude: Set; apply: boolean; includeOpen: boolean; + githubOnly: boolean; } { const exclude = new Set(); let apply = false; let includeOpen = false; + let githubOnly = false; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === "--apply") { @@ -94,6 +99,10 @@ function parseArgs(argv: string[]): { includeOpen = true; continue; } + if (a === "--github-only") { + githubOnly = true; + continue; + } if (a === "--exclude") { const raw = argv[++i]?.trim(); if (!raw) { @@ -114,7 +123,7 @@ function parseArgs(argv: string[]): { console.error(`Unknown argument: ${a}`); usage(); } - return { exclude, apply, includeOpen }; + return { exclude, apply, includeOpen, githubOnly }; } function prFromGithubEnvName(name: string): number | undefined { @@ -794,7 +803,12 @@ async function attemptDeletes( } async function main(): Promise { - const { exclude: manualExclude, apply, includeOpen } = parseArgs(process.argv.slice(2)); + const { + exclude: manualExclude, + apply, + includeOpen, + githubOnly, + } = parseArgs(process.argv.slice(2)); findRepoRoot(); // validate cwd / workspace const openListed = listOpenPullRequestNumbers(); @@ -814,7 +828,7 @@ async function main(): Promise { const mode = apply ? "APPLY" : "DRY-RUN"; console.log( - `[preview-cleanup-orphans] product=${PRODUCT_PREFIX} mode=${mode} includeOpen=${includeOpen} exclude=[${[...exclude].sort((a, b) => a - b).join(",")}]`, + `[preview-cleanup-orphans] product=${PRODUCT_PREFIX} mode=${mode} githubOnly=${githubOnly} includeOpen=${includeOpen} exclude=[${[...exclude].sort((a, b) => a - b).join(",")}]`, ); if (!includeOpen && openPrNumbers.size > 0) { console.log( @@ -826,9 +840,9 @@ async function main(): Promise { const cfToken = process.env["CLOUDFLARE_API_TOKEN"]?.trim(); const hasCfCreds = Boolean(accountId && cfToken); - if (apply && !hasCfCreds) { + if (apply && !githubOnly && !hasCfCreds) { console.error( - "[preview-cleanup-orphans] --apply aborted: CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN required (refusing to mutate GitHub while blind to Cloudflare)", + "[preview-cleanup-orphans] --apply aborted: CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN required (refusing to mutate GitHub while blind to Cloudflare). Use --github-only if CF was already destroyed.", ); process.exit(1); } @@ -836,7 +850,9 @@ async function main(): Promise { const discovered: Item[] = []; let scansComplete = true; - if (hasCfCreds && accountId && cfToken) { + if (githubOnly) { + console.log("[preview-cleanup-orphans] --github-only: skipping Cloudflare scan"); + } else if (hasCfCreds && accountId && cfToken) { const lists = await Promise.all([ listCfWorkers(accountId, cfToken), listCfD1(accountId, cfToken),