Skip to content

fix(ci): preview cleanup no longer fails on GitHub environment delete #245

fix(ci): preview cleanup no longer fails on GitHub environment delete

fix(ci): preview cleanup no longer fails on GitHub environment delete #245

Workflow file for this run

name: 🔎 Preview
on:
pull_request:
branches:
- main
types:
- opened
- reopened
- synchronize
- ready_for_review
- closed
workflow_dispatch:
inputs:
action:
description: Deploy or cleanup preview resources
required: true
type: choice
default: deploy
options:
- deploy
- cleanup
target:
description: Preview naming strategy
required: true
type: choice
default: branch
options:
- branch
- pr
pr_number:
description: PR number (required when target=pr)
required: false
type: string
preview_name:
description: Optional preview name override (target=branch)
required: false
type: string
permissions:
contents: read
pull-requests: write
concurrency:
group: >-
preview-${{ github.event.pull_request.number || format('{0}-{1}',
inputs.target, inputs.target == 'pr' && inputs.pr_number ||
inputs.preview_name || github.ref_name) }}
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
name: 🔎 Deploy Preview Resources
environment:
name:
preview-${{ github.event.pull_request.number || inputs.pr_number ||
github.run_id }}
url: ${{ steps.deploy_preview.outputs.url }}
if: >-
(github.event_name == 'pull_request' &&
github.event.action != 'closed' &&
github.event.pull_request.head.repo.fork == false &&
github.event.pull_request.draft == false) ||
(github.event_name == 'workflow_dispatch' &&
inputs.action == 'deploy')
steps:
- name: 📦 Checkout
uses: actions/checkout@v6.0.2
- name: 🧰 Setup Bun
uses: oven-sh/setup-bun@v2.2.0
with:
bun-version: latest
- name: 📥 Install Dependencies
run: bun install --frozen-lockfile
- name: 🧾 Resolve preview names
id: names
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
REF_NAME: ${{ github.ref_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
INPUT_TARGET: ${{ inputs.target }}
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
INPUT_PREVIEW_NAME: ${{ inputs.preview_name }}
run: |
set -euo pipefail
APP_NAME="epicflare"
if [ "$EVENT_NAME" = "pull_request" ]; then
PREVIEW_KIND="pr"
PREVIEW_ID="$PR_NUMBER"
else
PREVIEW_KIND="$INPUT_TARGET"
PREVIEW_ID=""
if [ "$PREVIEW_KIND" = "pr" ]; then
PREVIEW_ID="$INPUT_PR_NUMBER"
if [ -z "$PREVIEW_ID" ]; then
echo "inputs.pr_number is required when inputs.target=pr" >&2
exit 1
fi
else
PREVIEW_ID="$INPUT_PREVIEW_NAME"
if [ -z "$PREVIEW_ID" ]; then
PREVIEW_ID="$REF_NAME"
fi
fi
fi
# Cloudflare Worker names must be URL-safe; normalize to lower-kebab-case.
slug="$(echo "$PREVIEW_ID" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-+/-/g' | cut -c1-32)"
if [ -z "$slug" ]; then
slug="preview"
fi
if [ "$PREVIEW_KIND" = "pr" ]; then
APP_WORKER_NAME="${APP_NAME}-pr-${slug}"
else
APP_WORKER_NAME="${APP_NAME}-branch-${slug}"
fi
echo "preview_kind=$PREVIEW_KIND" >> "$GITHUB_OUTPUT"
echo "preview_id=$PREVIEW_ID" >> "$GITHUB_OUTPUT"
echo "worker_name=$APP_WORKER_NAME" >> "$GITHUB_OUTPUT"
- name: 🧱 Ensure preview resources (D1 + KV)
id: resources
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
APP_WORKER_NAME: ${{ steps.names.outputs.worker_name }}
run: |
set -euo pipefail
bun tools/ci/preview-resources.ts ensure --worker-name "$APP_WORKER_NAME" --out-config wrangler-preview.generated.json | tee -a "$GITHUB_OUTPUT"
- name: 🗄️ Apply D1 Migrations (preview)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ENV: preview
WRANGLER_CONFIG: ${{ steps.resources.outputs.wrangler_config }}
run:
bun ./wrangler-env.ts d1 migrations apply APP_DB --remote --config
"$WRANGLER_CONFIG"
- name: 🗄️ Apply D1 Migrations (preview mocks)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ENV: preview
run: |
set -euo pipefail
for generated in mock-servers/*/wrangler-preview.generated.json; do
if [ ! -f "$generated" ]; then
continue
fi
bun ./wrangler-env.ts d1 migrations apply APP_DB --remote --config "$generated"
done
- name: 🧪 Deploy preview mock Workers
id: deploy_mocks
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
APP_WORKER_NAME: ${{ steps.names.outputs.worker_name }}
run: |
set -euo pipefail
MOCK_API_TOKEN="$(openssl rand -hex 32)"
echo "::add-mask::$MOCK_API_TOKEN"
OVERRIDES_FILE="preview-secrets-overrides.env"
: > "$OVERRIDES_FILE"
mock_summary=""
mock_comment_summary=""
for dir in mock-servers/*; do
if [ ! -d "$dir" ]; then
continue
fi
if [ ! -f "$dir/wrangler.jsonc" ]; then
continue
fi
service="$(basename "$dir")"
service_key="$(echo "$service" | tr '[:lower:]-' '[:upper:]_')"
mock_worker_name="${APP_WORKER_NAME}-mock-${service}"
deploy_log="deploy-mock-${service}.log"
mock_config="$dir/wrangler-preview.generated.json"
if [ -f "$mock_config" ]; then
deploy_config="$mock_config"
else
deploy_config="$dir/wrangler.jsonc"
fi
bun tools/ci/sync-worker-secrets.ts --env "" --name "$mock_worker_name" --config "$deploy_config" --set "MOCK_API_TOKEN=$MOCK_API_TOKEN"
bunx wrangler deploy --env preview --name "$mock_worker_name" --config "$deploy_config" 2>&1 | tee "$deploy_log"
mock_url="$(node -e 'const fs = require("node:fs"); const text = fs.readFileSync(process.argv[1], "utf8").replace(/\u001b\[[0-9;]*m/g, ""); const worker = process.argv[2]; const escaped = worker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const workerUrlRegex = new RegExp(`https://(?:[a-zA-Z0-9-]+\\.)?${escaped}\\.[a-zA-Z0-9._-]+\\.workers\\.dev`, "g"); const matches = text.match(workerUrlRegex); process.stdout.write(matches?.at(-1) ?? "")' "$deploy_log" "$mock_worker_name")"
if [ -z "$mock_url" ]; then
echo "Failed to parse mock URL for $service." >&2
exit 1
fi
mock_version_preview_url="$(node -e 'const fs = require("node:fs"); const text = fs.readFileSync(process.argv[1], "utf8").replace(/\u001b\[[0-9;]*m/g, ""); const match = text.match(/(?:Version\s+)?Preview URL:\s*(https:\/\/\S+)/i); process.stdout.write(match?.[1] ?? "")' "$deploy_log")"
echo "${service_key}_API_BASE_URL=$mock_url" >> "$OVERRIDES_FILE"
echo "${service_key}_API_KEY=$MOCK_API_TOKEN" >> "$OVERRIDES_FILE"
mock_summary_line="- ${service}: ${mock_url} (\`${mock_worker_name}\`)"
mock_comment_summary_line="- ${service}: [${mock_url}/__mocks](${mock_url}/__mocks?token=${MOCK_API_TOKEN}) (\`${mock_worker_name}\`)"
if [ -n "$mock_version_preview_url" ]; then
mock_summary_line="${mock_summary_line} | version preview: ${mock_version_preview_url}"
mock_comment_summary_line="${mock_comment_summary_line} | version preview: [${mock_version_preview_url}](${mock_version_preview_url})"
fi
mock_summary="${mock_summary}\n${mock_summary_line}"
mock_comment_summary="${mock_comment_summary}\n${mock_comment_summary_line}"
done
if [ -n "$mock_summary" ]; then
mock_summary="$(printf "%b" "$mock_summary" | sed '1{/^$/d;}')"
fi
if [ -n "$mock_comment_summary" ]; then
mock_comment_summary="$(printf "%b" "$mock_comment_summary" | sed '1{/^$/d;}')"
fi
{
echo "mock_summary<<EOF"
printf "%b\n" "$mock_summary"
echo "EOF"
echo "mock_comment_summary<<EOF"
printf "%b\n" "$mock_comment_summary"
echo "EOF"
echo "overrides_file=$OVERRIDES_FILE"
} >> "$GITHUB_OUTPUT"
- name: 🔐 Sync preview app Worker secrets (bulk)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
APP_WORKER_NAME: ${{ steps.names.outputs.worker_name }}
OVERRIDES_FILE: ${{ steps.deploy_mocks.outputs.overrides_file }}
AI_GATEWAY_ID: ${{ secrets.AI_GATEWAY_ID_PREVIEW }}
run: |
set -euo pipefail
ENV_FILE=".env.example"
if [ ! -f "$ENV_FILE" ]; then
echo "Missing $ENV_FILE (required for preview deploy secrets)." >&2
exit 1
fi
if [ ! -f "$OVERRIDES_FILE" ]; then
echo "Missing overrides file ($OVERRIDES_FILE) from mock deploy step." >&2
exit 1
fi
bun tools/ci/sync-worker-secrets.ts --env "" --name "$APP_WORKER_NAME" --from-dotenv "$ENV_FILE" --from-dotenv "$OVERRIDES_FILE" --set-from-env AI_GATEWAY_ID --generate-cookie-secret --include-empty
- name: ☁️ Deploy preview app Worker
id: deploy_preview
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ENV: preview
APP_WORKER_NAME: ${{ steps.names.outputs.worker_name }}
WRANGLER_CONFIG: ${{ steps.resources.outputs.wrangler_config }}
DEPLOY_COMMIT_SHA: ${{ github.sha }}
run: |
set -euo pipefail
echo "worker_name=$APP_WORKER_NAME" >> "$GITHUB_OUTPUT"
bun run deploy -- --name "$APP_WORKER_NAME" --config "$WRANGLER_CONFIG" --var "APP_COMMIT_SHA:${DEPLOY_COMMIT_SHA}" 2>&1 | tee deploy.log
PREVIEW_URL="$(node -e 'const fs = require("node:fs"); const text = fs.readFileSync(process.argv[1], "utf8").replace(/\u001b\[[0-9;]*m/g, ""); const worker = process.argv[2]; const escaped = worker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const workerUrlRegex = new RegExp(`https://(?:[a-zA-Z0-9-]+\\.)?${escaped}\\.[a-zA-Z0-9._-]+\\.workers\\.dev`, "g"); const matches = text.match(workerUrlRegex); process.stdout.write(matches?.at(-1) ?? "")' deploy.log "$APP_WORKER_NAME")"
if [ -n "$PREVIEW_URL" ]; then
echo "url=$PREVIEW_URL" >> "$GITHUB_OUTPUT"
fi
PREVIEW_VERSION_URL="$(node -e 'const fs = require("node:fs"); const text = fs.readFileSync(process.argv[1], "utf8").replace(/\u001b\[[0-9;]*m/g, ""); const match = text.match(/(?:Version\s+)?Preview URL:\s*(https:\/\/\S+)/i); process.stdout.write(match?.[1] ?? "")' deploy.log)"
if [ -n "$PREVIEW_VERSION_URL" ]; then
echo "version_preview_url=$PREVIEW_VERSION_URL" >> "$GITHUB_OUTPUT"
fi
- name: 🌱 Seed preview test data
shell: bash
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ENV: preview
WRANGLER_CONFIG: ${{ steps.resources.outputs.wrangler_config }}
run: |
set -euo pipefail
bun tools/seed-test-data.ts --remote --config "$WRANGLER_CONFIG" --email kody@kcd.dev --password kodylovesyou
- name: 🩺 Healthcheck (preview)
shell: bash
env:
PREVIEW_URL: ${{ steps.deploy_preview.outputs.url }}
EXPECTED_COMMIT_SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [ -z "${PREVIEW_URL:-}" ]; then
echo "Missing preview URL output; cannot run healthcheck." >&2
exit 1
fi
if [ -z "${EXPECTED_COMMIT_SHA:-}" ]; then
echo "Missing expected commit SHA; cannot verify deployment version." >&2
exit 1
fi
HEALTHCHECK_URL="${PREVIEW_URL%/}/health"
echo "Healthcheck URL: $HEALTHCHECK_URL"
attempts=30
delay_seconds=3
for i in $(seq 1 "$attempts"); do
echo "Attempt $i/$attempts"
if curl --fail --silent --show-error --location --max-time 10 \
--header "Accept: application/json" \
"$HEALTHCHECK_URL" > health.json && \
node -e "const fs = require('node:fs'); const body = fs.readFileSync('health.json','utf8'); const json = JSON.parse(body); const expected = process.env.EXPECTED_COMMIT_SHA; if (json?.ok !== true) { console.error('healthcheck-unexpected-response', json); process.exit(1); } if (json?.commitSha !== expected) { console.error('healthcheck-unexpected-commit-sha', { expected, actual: json?.commitSha }); process.exit(1); } console.log('healthcheck-ok', json);"; then
exit 0
fi
sleep "$delay_seconds"
done
echo "Healthcheck failed after ${attempts} attempts: $HEALTHCHECK_URL" >&2
if [ -f health.json ]; then
echo "Last response body:" >&2
cat health.json >&2
fi
exit 1
- name: 🧾 Write preview link summary
if: steps.deploy_preview.outputs.url != ''
env:
PREVIEW_URL: ${{ steps.deploy_preview.outputs.url }}
PREVIEW_VERSION_URL:
'${{ steps.deploy_preview.outputs.version_preview_url }}'
WORKER_NAME: ${{ steps.deploy_preview.outputs.worker_name }}
MOCK_SUMMARY: ${{ steps.deploy_mocks.outputs.mock_summary }}
D1_DATABASE_NAME: ${{ steps.resources.outputs.d1_database_name }}
OAUTH_KV_TITLE: ${{ steps.resources.outputs.oauth_kv_title }}
run: |
{
echo "### Preview"
echo ""
echo "$PREVIEW_URL"
echo ""
printf 'Worker: `%s`\n' "$WORKER_NAME"
if [ -n "${PREVIEW_VERSION_URL:-}" ]; then
printf 'Version preview: %s\n' "$PREVIEW_VERSION_URL"
fi
if [ -n "${D1_DATABASE_NAME:-}" ]; then
printf 'D1: `%s`\n' "$D1_DATABASE_NAME"
fi
if [ -n "${OAUTH_KV_TITLE:-}" ]; then
printf 'KV: `%s`\n' "$OAUTH_KV_TITLE"
fi
if [ -n "${MOCK_SUMMARY:-}" ]; then
echo ""
echo "### Mocks"
printf '%s\n' "$MOCK_SUMMARY"
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: 💬 Comment Preview URL on PR
if: >-
github.event_name == 'pull_request' &&
steps.deploy_preview.outputs.url != ''
uses: actions/github-script@v8.0.0
env:
PREVIEW_URL: ${{ steps.deploy_preview.outputs.url }}
PREVIEW_VERSION_URL:
'${{ steps.deploy_preview.outputs.version_preview_url }}'
WORKER_NAME: ${{ steps.deploy_preview.outputs.worker_name }}
MOCK_SUMMARY: ${{ steps.deploy_mocks.outputs.mock_comment_summary }}
D1_DATABASE_NAME: ${{ steps.resources.outputs.d1_database_name }}
OAUTH_KV_TITLE: ${{ steps.resources.outputs.oauth_kv_title }}
with:
script: |
const marker = "<!-- epicflare-preview-url -->";
const bodyLines = [
marker,
`🔎 Preview deployed: ${process.env.PREVIEW_URL}`,
"",
`Worker: \`${process.env.WORKER_NAME}\``,
];
if (process.env.PREVIEW_VERSION_URL?.trim()) {
bodyLines.push(
`Version preview: ${process.env.PREVIEW_VERSION_URL.trim()}`,
);
}
if (process.env.D1_DATABASE_NAME?.trim()) {
bodyLines.push(`D1: \`${process.env.D1_DATABASE_NAME.trim()}\``);
}
if (process.env.OAUTH_KV_TITLE?.trim()) {
bodyLines.push(`KV: \`${process.env.OAUTH_KV_TITLE.trim()}\``);
}
if (process.env.MOCK_SUMMARY?.trim()) {
bodyLines.push("");
bodyLines.push("Mocks:");
bodyLines.push(process.env.MOCK_SUMMARY.trim());
}
const body = bodyLines.join("\n");
const { owner, repo } = context.repo;
const issue_number = context.payload.pull_request.number;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
const existing = comments.find((c) => c.body?.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body,
});
}
cleanup:
runs-on: ubuntu-latest
name: 🧹 Cleanup Preview Resources
if: >-
(github.event_name == 'pull_request' &&
github.event.action == 'closed' &&
github.event.pull_request.head.repo.fork == false) ||
(github.event_name == 'workflow_dispatch' &&
inputs.action == 'cleanup')
steps:
- name: 📦 Checkout
uses: actions/checkout@v6.0.2
- name: 🧰 Setup Bun
uses: oven-sh/setup-bun@v2.2.0
with:
bun-version: latest
- name: 📥 Install Dependencies
run: bun install --frozen-lockfile
- name: 🧾 Resolve preview names
id: names
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
REF_NAME: ${{ github.ref_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
INPUT_TARGET: ${{ inputs.target }}
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
INPUT_PREVIEW_NAME: ${{ inputs.preview_name }}
run: |
set -euo pipefail
APP_NAME="epicflare"
if [ "$EVENT_NAME" = "pull_request" ]; then
PREVIEW_KIND="pr"
PREVIEW_ID="$PR_NUMBER"
else
PREVIEW_KIND="$INPUT_TARGET"
PREVIEW_ID=""
if [ "$PREVIEW_KIND" = "pr" ]; then
PREVIEW_ID="$INPUT_PR_NUMBER"
if [ -z "$PREVIEW_ID" ]; then
echo "inputs.pr_number is required when inputs.target=pr" >&2
exit 1
fi
else
PREVIEW_ID="$INPUT_PREVIEW_NAME"
if [ -z "$PREVIEW_ID" ]; then
PREVIEW_ID="$REF_NAME"
fi
fi
fi
slug="$(echo "$PREVIEW_ID" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-+/-/g' | cut -c1-32)"
if [ -z "$slug" ]; then
slug="preview"
fi
if [ "$PREVIEW_KIND" = "pr" ]; then
APP_WORKER_NAME="${APP_NAME}-pr-${slug}"
else
APP_WORKER_NAME="${APP_NAME}-branch-${slug}"
fi
echo "worker_name=$APP_WORKER_NAME" >> "$GITHUB_OUTPUT"
- name: 🗑️ Delete preview Workers
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
APP_WORKER_NAME: ${{ steps.names.outputs.worker_name }}
run: |
set -euo pipefail
delete_worker() {
local name="$1"
local output=""
set +e
output="$(bunx wrangler delete "$name" --env preview --force 2>&1)"
local exit_code="$?"
set -e
if [ "$exit_code" -eq 0 ]; then
echo "$output"
return 0
fi
local lower
lower="$(echo "$output" | tr '[:upper:]' '[:lower:]')"
if [[ "$lower" == *"not found"* ]] || [[ "$lower" == *"no such"* ]] || [[ "$lower" == *"does not exist"* ]]; then
echo "Worker $name already deleted."
return 0
fi
echo "$output" >&2
return "$exit_code"
}
delete_worker "$APP_WORKER_NAME"
for dir in mock-servers/*; do
if [ ! -d "$dir" ]; then
continue
fi
if [ ! -f "$dir/wrangler.jsonc" ]; then
continue
fi
service="$(basename "$dir")"
mock_worker_name="${APP_WORKER_NAME}-mock-${service}"
delete_worker "$mock_worker_name"
done
- name: 🧹 Delete preview resources (D1 + KV)
if: always()
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
APP_WORKER_NAME: ${{ steps.names.outputs.worker_name }}
run: |
set -euo pipefail
bun tools/ci/preview-resources.ts cleanup --worker-name "$APP_WORKER_NAME"
- name: 🏷️ Delete GitHub preview environment
if: always()
uses: actions/github-script@v8.0.0
env:
EVENT_NAME: ${{ github.event_name }}
PR_NUMBER: ${{ github.event.pull_request.number || '' }}
INPUT_TARGET: ${{ inputs.target }}
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
with:
# The default GITHUB_TOKEN cannot delete deployment environments (403).
# Optional: set repo secret PREVIEW_ENV_CLEANUP_TOKEN to a PAT with repo
# scope (classic) or Administration: write (fine-grained) for this repo.
github-token: ${{ secrets.PREVIEW_ENV_CLEANUP_TOKEN || github.token }}
script: |
const eventName = process.env.EVENT_NAME;
let envName;
if (eventName === "pull_request") {
const n = process.env.PR_NUMBER;
if (!n) {
core.info("No PR number on event; skipping GitHub environment delete.");
return;
}
envName = `preview-${n}`;
} else if (
eventName === "workflow_dispatch" &&
process.env.INPUT_TARGET === "pr"
) {
const n = process.env.INPUT_PR_NUMBER;
if (!n) {
core.info(
"Manual cleanup without pr_number; skipping GitHub environment delete.",
);
return;
}
envName = `preview-${n}`;
} else {
core.info(
"Skipping GitHub environment delete (branch-targeted cleanups use a different naming scheme).",
);
return;
}
const { owner, repo } = context.repo;
try {
await github.request(
"DELETE /repos/{owner}/{repo}/environments/{environment_name}",
{
owner,
repo,
environment_name: envName,
},
);
core.notice(`Deleted GitHub environment: ${envName}`);
} catch (e) {
if (e.status === 404) {
core.info(
`GitHub environment not found (already deleted): ${envName}`,
);
} else if (e.status === 403) {
core.warning(
[
`Cannot delete GitHub environment "${envName}" with the current token (403).`,
"The default workflow token is not allowed to delete environments.",
"Add repository secret PREVIEW_ENV_CLEANUP_TOKEN (PAT: classic `repo`, or fine-grained with Administration read/write on this repository) to enable automatic removal.",
].join(" "),
);
} else {
throw e;
}
}