fix(preview): use worker list metadata for cleanup (#1799)

* fix(preview): use worker list metadata for cleanup

* fix(preview): handle cleanup edge cases

* fix(preview): harden scheduled cleanup errors

* feat(preview): add warmup screen and readiness gate

* fix(preview): report success after image deploy

* fix(preview): stop blocking healthy previews on stale status
This commit is contained in:
Sure Admin (bot)
2026-05-16 15:26:30 +02:00
committed by GitHub
parent 6a765a90c6
commit d74b1b2a11
3 changed files with 294 additions and 30 deletions

View File

@@ -139,11 +139,28 @@ jobs:
# Get list of all preview workers
echo "Fetching list of preview workers..."
# Use Cloudflare API to list workers
WORKERS=$(curl -s -X GET \
# Use Cloudflare API to list workers and read modified_on from the list response.
# The per-script endpoint returns raw script content, not JSON metadata.
WORKERS_RESPONSE=$(curl -fsS -X GET \
"https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json" | jq -r '.result[] | select(.id | startswith("sure-preview-")) | .id')
-H "Content-Type: application/json") || {
echo "Failed to fetch preview worker list from Cloudflare"
exit 1
}
if ! echo "$WORKERS_RESPONSE" | jq -e '.success == true and (.result | type == "array")' >/dev/null 2>&1; then
echo "Cloudflare API returned an invalid worker list response"
echo "$WORKERS_RESPONSE" | jq -c '.errors // .'
exit 1
fi
WORKERS=$(echo "$WORKERS_RESPONSE" | jq -r '
.result[]
| select(.id | startswith("sure-preview-"))
| [.id, (.modified_on // "")]
| @tsv
')
if [ -z "$WORKERS" ]; then
echo "No preview workers found"
@@ -151,46 +168,49 @@ jobs:
fi
echo "Found preview workers:"
echo "$WORKERS"
echo "$WORKERS" | cut -f1
# Check each worker's deployment time
CUTOFF_TIME=$(date -d '24 hours ago' +%s)
for WORKER in $WORKERS; do
while IFS=$'\t' read -r WORKER MODIFIED_ON; do
[ -n "$WORKER" ] || continue
echo "Checking $WORKER..."
# Get worker details to find last deployment time
WORKER_INFO=$(curl -s -X GET \
"https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts/$WORKER" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json")
if [ -z "$MODIFIED_ON" ]; then
echo "No modified_on timestamp for $WORKER; skipping"
continue
fi
MODIFIED_ON=$(echo "$WORKER_INFO" | jq -r '.result.modified_on // empty')
if [ -n "$MODIFIED_ON" ]; then
MODIFIED_TS=$(date -d "$MODIFIED_ON" +%s 2>/dev/null || echo "0")
if [ "$MODIFIED_TS" -lt "$CUTOFF_TIME" ]; then
echo "Worker $WORKER is older than 24 hours, deleting..."
wrangler delete --name "$WORKER" --force || echo "Failed to delete $WORKER"
if ! MODIFIED_TS=$(date -d "$MODIFIED_ON" +%s 2>/dev/null); then
echo "Invalid modified_on timestamp for $WORKER ($MODIFIED_ON); skipping"
continue
fi
if [ "$MODIFIED_TS" -lt "$CUTOFF_TIME" ]; then
echo "Worker $WORKER is older than 24 hours, deleting..."
if wrangler delete --name "$WORKER" --force; then
# Extract PR number and cleanup GitHub deployment
PR_NUM=$(echo "$WORKER" | sed 's/sure-preview-//')
if [ -n "$PR_NUM" ]; then
if [[ "$PR_NUM" =~ ^[1-9][0-9]*$ ]]; then
echo "Cleaning up GitHub deployment for PR #$PR_NUM"
gh api \
-X GET "/repos/${{ github.repository }}/deployments?environment=preview-pr-$PR_NUM" \
--jq '.[].id' | while read -r DEPLOY_ID; do
--jq '.[].id' 2>/dev/null | while read -r DEPLOY_ID; do
gh api \
-X POST "/repos/${{ github.repository }}/deployments/$DEPLOY_ID/statuses" \
-f state=inactive \
-f description="Preview expired after 24 hours" || true
done
done || echo "No deployments to cleanup or error occurred"
else
echo "Could not extract a valid PR number from $WORKER; skipping deployment cleanup"
fi
else
echo "Worker $WORKER is still within 24-hour window, keeping..."
echo "Failed to delete $WORKER; skipping deployment status update"
fi
else
echo "Worker $WORKER is still within 24-hour window, keeping..."
fi
done
done <<< "$WORKERS"
echo "Cleanup complete"

View File

@@ -112,6 +112,13 @@ jobs:
PREVIEW_URL="https://sure-preview-${{ github.event.pull_request.number }}.${{ secrets.CLOUDFLARE_WORKERS_SUBDOMAIN }}.workers.dev"
echo "preview_url=${PREVIEW_URL}" >> "$GITHUB_OUTPUT"
- name: Warm preview container
env:
PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }}
run: |
echo "Triggering preview wake-up..."
curl -fsS "$PREVIEW_URL/" >/dev/null || true
- name: Update Deployment Status
if: always() && steps.deployment.outputs.result
uses: actions/github-script@v7
@@ -135,7 +142,7 @@ jobs:
const previewUrl = '${{ steps.deploy.outputs.preview_url }}';
const commentBody = `## 🚀 Preview Deployment Ready
Your preview environment has been deployed to Cloudflare Containers.
Your preview environment has been deployed to Cloudflare Containers with the PR's Docker image.
**Preview URL:** ${previewUrl}

View File

@@ -4,8 +4,61 @@ interface Env {
RAILS_CONTAINER: DurableObjectNamespace<RailsContainer>;
}
interface DiagnosticPayload {
stage?: string;
detail?: string;
}
interface DiagnosticRecord {
event?: string;
at?: string;
payload?: DiagnosticPayload;
state?: { status?: string; lastChange?: number };
message?: string;
}
interface PreviewProgress {
phase: "cold" | "warming" | "loading-demo-data" | "ready" | "failed";
stage: string | null;
message: string;
detail: string;
}
interface PreviewStatusPayload {
state: unknown;
containerRunning: boolean;
diagnostics: DiagnosticRecord | null;
diagnosticsHistory: DiagnosticRecord[];
previewReady: boolean;
previewFailed: boolean;
progress: PreviewProgress;
}
const DIAGNOSTICS_KEY = "preview-diagnostics";
const DIAGNOSTICS_HISTORY_KEY = "preview-diagnostics-history";
const READY_STAGES = new Set(["demo-data-ready", "demo-data-skip"]);
const FAILED_STAGES = new Set(["demo-data-failed", "failed"]);
const WAITING_MESSAGES: Record<string, string> = {
boot: "Waking preview…",
"redis-start": "Starting Redis…",
"redis-ready": "Redis is ready.",
"postgres-start": "Starting PostgreSQL…",
"postgres-ready": "PostgreSQL is ready.",
"postgres-already-running": "PostgreSQL is already running.",
"db-setup": "Setting up the preview database…",
"db-prepare": "Running database setup…",
"db-prepare-done": "Database setup finished.",
"demo-data-check": "Checking sample data…",
"demo-data-user-present": "Found the demo user. Verifying sample data…",
"demo-data-deferred": "Rails is up. Loading sample data…",
"demo-data-load": "Loading sample data…",
"demo-data-ready": "Sample data is ready.",
"demo-data-skip": "Sample data is already ready.",
"demo-data-failed": "Sample data failed to load.",
"rails-start": "Starting Rails…",
"rails-up-ready": "Rails is up. Finishing sample data…",
"rails-up-timeout": "Rails is taking longer than expected to start.",
};
export class RailsContainer extends Container {
defaultPort = 3000;
@@ -53,16 +106,191 @@ export class RailsContainer extends Container {
await this.ctx.storage.put(DIAGNOSTICS_HISTORY_KEY, history);
}
private async getDiagnostics(): Promise<{
state: unknown;
containerRunning: boolean;
diagnostics: DiagnosticRecord | null;
diagnosticsHistory: DiagnosticRecord[];
}> {
return {
state: await this.getState(),
containerRunning: this.runtimeContainer.running,
diagnostics: ((await this.ctx.storage.get(DIAGNOSTICS_KEY)) as DiagnosticRecord | undefined) ?? null,
diagnosticsHistory:
((await this.ctx.storage.get(DIAGNOSTICS_HISTORY_KEY)) as DiagnosticRecord[] | undefined) ?? [],
};
}
private async probeRailsUp(): Promise<boolean> {
try {
const response = await this.containerFetch(new Request("https://container.internal/up"), this.defaultPort);
return response.ok;
} catch {
return false;
}
}
private async buildPreviewStatus(base: {
state: unknown;
containerRunning: boolean;
diagnostics: DiagnosticRecord | null;
diagnosticsHistory: DiagnosticRecord[];
}, options?: { probe?: boolean }): Promise<PreviewStatusPayload> {
const allDiagnostics = [...base.diagnosticsHistory, ...(base.diagnostics ? [base.diagnostics] : [])];
const entrypointDiagnostics = allDiagnostics.filter(
(item) => item.event === "entrypoint" && typeof item.payload?.stage === "string"
);
const latestEntrypoint = entrypointDiagnostics.at(-1) ?? null;
const latestStage = latestEntrypoint?.payload?.stage ?? null;
const latestDetail = latestEntrypoint?.payload?.detail ?? base.diagnostics?.message ?? "";
const sampleDataReady = entrypointDiagnostics.some((item) => READY_STAGES.has(item.payload?.stage ?? ""));
const liveProbeReady = options?.probe ? await this.probeRailsUp() : false;
const railsResponding =
liveProbeReady ||
(typeof base.state === "object" && base.state !== null && "status" in base.state
? (base.state as { status?: string }).status === "healthy"
: false) ||
entrypointDiagnostics.some((item) => item.payload?.stage === "rails-up-ready");
const previewReady = liveProbeReady || (sampleDataReady && railsResponding);
const previewFailed =
entrypointDiagnostics.some((item) => FAILED_STAGES.has(item.payload?.stage ?? "")) ||
base.diagnostics?.event === "error";
let phase: PreviewProgress["phase"] = "cold";
if (previewFailed) {
phase = "failed";
} else if (previewReady) {
phase = "ready";
} else if (
latestStage === "demo-data-load" ||
latestStage === "demo-data-deferred" ||
latestStage === "rails-up-ready" ||
latestStage === "demo-data-check" ||
latestStage === "demo-data-user-present"
) {
phase = "loading-demo-data";
} else if (base.containerRunning || latestEntrypoint) {
phase = "warming";
}
const message = sampleDataReady && !previewReady
? "Finishing preview startup…"
: (latestStage ? WAITING_MESSAGES[latestStage] : undefined) ??
(previewFailed
? "Preview startup hit an error."
: previewReady
? "Preview is ready."
: base.containerRunning
? "Warming preview…"
: "Starting preview…");
return {
...base,
previewReady,
previewFailed,
progress: {
phase,
stage: latestStage,
message,
detail: latestDetail,
},
};
}
private wantsHtml(request: Request): boolean {
if (request.method !== "GET") return false;
const accept = request.headers.get("accept") ?? "";
const secFetchDest = request.headers.get("sec-fetch-dest") ?? "";
return accept.includes("text/html") || secFetchDest === "document";
}
private renderWaitPage(request: Request, status: PreviewStatusPayload, errorMessage?: string): Response {
const targetPath = new URL(request.url).pathname + new URL(request.url).search;
const escapedTargetPath = JSON.stringify(targetPath);
const escapedMessage = JSON.stringify(status.progress.message);
const escapedDetail = JSON.stringify(
status.progress.detail || errorMessage || "This preview is waking up and loading sample data."
);
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Waking preview…</title>
<style>
:root { color-scheme: light dark; }
body { margin: 0; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #0b1220; color: #e5eefc; }
.wrap { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.card { width: min(100%, 520px); background: rgba(15, 23, 42, 0.92); border: 1px solid rgba(148, 163, 184, 0.2); border-radius: 20px; padding: 28px; box-shadow: 0 20px 50px rgba(0,0,0,0.35); }
.spinner { width: 42px; height: 42px; border-radius: 999px; border: 4px solid rgba(148,163,184,0.28); border-top-color: #60a5fa; animation: spin 0.9s linear infinite; margin-bottom: 18px; }
h1 { margin: 0 0 10px; font-size: 1.5rem; }
p { margin: 0; line-height: 1.55; color: #cbd5e1; }
.detail { margin-top: 12px; font-size: 0.95rem; color: #93c5fd; }
.hint { margin-top: 18px; font-size: 0.9rem; color: #94a3b8; }
.error { margin-top: 18px; color: #fca5a5; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<div class="spinner" aria-hidden="true"></div>
<h1 id="message"></h1>
<p id="detail"></p>
<p class="hint">Please wait — this preview is cold-starting and will redirect automatically when the sample data is ready.</p>
<p class="error" id="error"></p>
</div>
</div>
<script>
const targetPath = ${escapedTargetPath};
const messageEl = document.getElementById("message");
const detailEl = document.getElementById("detail");
const errorEl = document.getElementById("error");
const update = (status) => {
messageEl.textContent = status?.progress?.message || ${escapedMessage};
detailEl.textContent = status?.progress?.detail || ${escapedDetail};
if (status?.previewFailed) {
errorEl.textContent = "Preview startup hit an error. Still retrying — refresh if this persists.";
}
};
update(null);
const poll = async () => {
try {
const response = await fetch("/_container_status", { cache: "no-store" });
if (!response.ok) throw new Error("status " + response.status);
const status = await response.json();
update(status);
if (status.previewReady) {
window.location.replace(targetPath);
return;
}
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
errorEl.textContent = "Still waking the preview (" + reason + ").";
}
window.setTimeout(poll, 1500);
};
window.setTimeout(poll, 1500);
</script>
</body>
</html>`;
return new Response(html, {
status: status.previewFailed ? 503 : 202,
headers: {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-store, max-age=0",
"retry-after": "3",
},
});
}
override async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/_container_status") {
return Response.json({
state: await this.getState(),
containerRunning: this.runtimeContainer.running,
diagnostics: (await this.ctx.storage.get(DIAGNOSTICS_KEY)) ?? null,
diagnosticsHistory: (await this.ctx.storage.get(DIAGNOSTICS_HISTORY_KEY)) ?? [],
});
return Response.json(await this.buildPreviewStatus(await this.getDiagnostics(), { probe: true }));
}
if (url.pathname === "/_container_event" && request.method === "POST") {
@@ -84,6 +312,15 @@ export class RailsContainer extends Container {
message: error instanceof Error ? error.message : String(error),
});
const status = await this.buildPreviewStatus(await this.getDiagnostics());
if (this.wantsHtml(request) && !status.previewReady) {
return this.renderWaitPage(
request,
status,
error instanceof Error ? error.message : String(error)
);
}
return new Response(
`Failed to serve preview container: ${error instanceof Error ? error.message : String(error)}`,
{ status: 500 }