Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 128 additions & 35 deletions .github/scripts/pr-preview/cleanup-github-preview-env.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@

/**
* Best-effort teardown of GitHub Deployment metadata for a closed PR preview.
* Marks all deployments for `preview-pr-<n>` inactive, then deletes the Environment
* when the token allows (soft-fail on permission errors).
*
* Marks matching deployments inactive. Deletes legacy `preview-pr-<n>` 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: {
Expand All @@ -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;
Expand All @@ -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) {
Expand All @@ -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}`,
);
}
}
Expand All @@ -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;
35 changes: 24 additions & 11 deletions .github/workflows/pr-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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-<n>`** Deployments/Environment.
# After a successful destroy, best-effort deactivates GitHub Deployments for this PR
# (shared Environment **`preview`**, plus legacy **`preview-pr-<n>`**). 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 <n>`** 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.
Expand Down Expand Up @@ -128,13 +130,16 @@ 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'
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
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 });

Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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: |
Expand All @@ -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 = "<!-- pr-preview -->";

Expand All @@ -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"
Expand Down Expand Up @@ -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", "");
Expand Down
Loading
Loading