mirror of
https://github.com/we-promise/sure.git
synced 2026-07-12 12:55:20 +00:00
* fix(preview): bind :3000 instantly and bound diagnostics posts The trusted preview deploy chain now succeeds end to end (after #2124, #2207, #2217), but the preview container itself dies on Cloudflare with "Container crashed while checking for ports" and no entrypoint diagnostics ever arrive (run 27186150190). Two compounding causes, both reproduced/measured locally against the pinned @cloudflare/containers 0.3.3 behavior: 1. The port window is unwinnable. The library waits a hardcoded ~20s for the container port, but the entrypoint only binds :3000 after redis, postgres, and rails db:prepare complete -- measured at 69s under a basic instance's 1/4 vCPU. Fix: bind :3000 within ~1s via a tiny Ruby placeholder responder (static 503 + meta-refresh, input ignored), verified with a TCP connect poll, and released just before the real server starts. The worker still gates previewReady on the real Rails /up probe and sample data, so readiness semantics are unchanged. 2. Diagnostics could stall boot and never deliver. emit_status used curl with no timeout against the worker, whose Durable Object can be unresponsive while it waits for this container's port (observed as 15s status timeouts in the failed run) -- so boot could deadlock against the port check, and failure detail (#2217) never reached the diagnostics artifact. Fix: --connect-timeout 2 --max-time 5 on all posts, progress events fire-and-forget in the background, and failure paths flush synchronously before exit. Validated locally at Cloudflare basic limits (1 GiB / 0.25 vCPU): first :3000 response in 1s, full boot to Rails /up=200 at ~76s with no OOM, and a forced postgres failure exits 1 in 4s with the failure event flushed. With the port window satisfied, the next real failure will finally surface postgres detail in _container_status and CI artifacts. * fix(preview): never read from placeholder clients Superagent flagged on PR #2286 that the single-threaded :3000 placeholder blocked on client.readpartial, so one connection that sends nothing -- such as a bare TCP port probe, which is exactly what the Cloudflare port check performs -- would wedge the accept loop and starve every later probe. The response is static, so drop the read entirely: each connection is written the 503 warming page and closed immediately, making the loop effectively non-blocking per client. Regression-tested by holding three idle TCP connections open while HTTP probes still answered 503 immediately; full boot under basic-instance limits (1 GiB / 0.25 vCPU) still reaches Rails /up=200 with the placeholder answering at 1s. * fix(preview): stop postgres crashing on Cloudflare's small /dev/shm This is the actual root cause of the preview container dying on Cloudflare with "Container crashed while checking for ports" (run 27341808838) and the maintainer's "dies immediately after postgres-start" (#2217). Reproduced locally: running the preview image with a tiny /dev/shm and a WRITABLE root (the realistic Cloudflare model) crashes postgres on startup with: FATAL: could not resize shared memory segment "/PostgreSQL..." to 1048576 bytes: No space left on device PostgreSQL 17 defaults to dynamic_shared_memory_type = posix, which allocates dynamic shared memory in /dev/shm. Cloudflare Containers provide only a tiny /dev/shm, so postgres FATALs before the entrypoint can bind a port, and the container exits -> the supervisor reports it crashed during the port check. Local Docker hid this because its default /dev/shm is 64MB. Memory was ruled out (boots fine at -m 512m); it is specifically /dev/shm. Fix: set dynamic_shared_memory_type = mmap so DSM is file-backed in the data directory instead of /dev/shm. The build comments out the default posix line, appends mmap, verifies the result, and fails hard if postgresql.conf is missing so this critical setting cannot silently regress. Also log fail_preview reasons to stderr so the real failure is captured by Cloudflare container observability even when the HTTP diagnostics channel is blocked by the worker's port wait. Verified at Cloudflare basic-equivalent limits (1 GiB / 0.25 vCPU, /dev/shm 64k): before, exit 1 right after "Starting PostgreSQL..."; after, "PostgreSQL is ready" -> Rails /up=200 (~97s), placeholder answering :3000 within 1s. Normal-resource boot still reaches /up=200 in ~9s. * fix(preview): use standard-1 instance and widen CI readiness poll Two changes the preview needs to actually deploy and be reported ready on Cloudflare, both validated by deploying to a real CF account. - instance_type basic -> standard-1. The container runs postgres + redis + puma AND generates the full demo dataset (Demo::Generator, ~12 years of transactions), which peaks just over basic's 1 GiB and OOM-kills the container (exit 137) before demo-data-ready. standard-1 (1/2 vCPU, 4 GiB) completes it. Measured on real Cloudflare: rails ready ~46s, demo data ~149s, peak well under 4 GiB, no OOM. - "Collect preview diagnostics" poll budget 40 -> 100 (~128s -> ~350s), with the matching guard in bin/preview_deploy_security_check.rb. A real CF standard-1 run reached previewReady at ~195s, so the old 40-poll budget would have failed a working preview before it finished warming up. The loop still breaks early on previewReady/previewFailed. * fix(preview): apply DSM override to the cluster the entrypoint starts The build selected postgresql.conf via `find ... | head -1`, which picks an arbitrary cluster, while the entrypoint starts the highest-version cluster (ls /etc/postgresql | sort -V | tail -1). Today only PG17 is installed so they coincide, but if a second major version were ever present the override could land on a cluster that never runs, silently reintroducing the /dev/shm crash. Derive PG_CONF from the same highest-version cluster the entrypoint starts so the dynamic_shared_memory_type=mmap override always applies to the active cluster. Verified: build edits /etc/postgresql/17/main/postgresql.conf, and at runtime under a tiny /dev/shm postgres starts with SHOW dynamic_shared_memory_type = mmap. * fix(preview): apply pg_hba trust rules to the cluster the entrypoint starts Same latent issue as the dynamic_shared_memory_type override: the build wrote the local `trust` rules to `find ... | head -1` (arbitrary cluster), while the entrypoint starts the highest-version cluster. With a single PG17 they coincide, but a second major version would send the trust rules to a cluster that never runs, breaking the entrypoint's trust-auth db setup. Derive PG_HBA from the same highest-version cluster (ls /etc/postgresql | sort -V | tail -1) and fail the build if it's missing. Verified under a tiny /dev/shm: postgres starts and CREATE ROLE/CREATE DATABASE succeed via trust.
637 lines
27 KiB
YAML
637 lines
27 KiB
YAML
name: Deploy PR Preview
|
|
|
|
on:
|
|
workflow_run:
|
|
workflows: ["Pull Request"]
|
|
types: [completed]
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
preview-gate:
|
|
if: |
|
|
github.event.workflow_run.event == 'pull_request' &&
|
|
github.event.workflow_run.conclusion == 'success'
|
|
name: Validate preview deployment gates
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
permissions:
|
|
actions: read
|
|
contents: read
|
|
pull-requests: read
|
|
outputs:
|
|
artifact_name: ${{ steps.preview.outputs.artifact_name }}
|
|
head_sha: ${{ steps.preview.outputs.head_sha }}
|
|
is_fork: ${{ steps.preview.outputs.is_fork }}
|
|
pr_number: ${{ steps.preview.outputs.pr_number }}
|
|
resolution_source: ${{ steps.preview.outputs.resolution_source }}
|
|
should_deploy: ${{ steps.preview.outputs.should_deploy }}
|
|
|
|
steps:
|
|
- name: Checkout trusted preview resolver
|
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
with:
|
|
ref: ${{ github.event.repository.default_branch }}
|
|
path: trusted-preview-resolver
|
|
persist-credentials: false
|
|
sparse-checkout: |
|
|
workers/preview/deploy
|
|
|
|
- name: Resolve preview request
|
|
id: preview
|
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
|
with:
|
|
script: |
|
|
const { resolvePreviewRequest } = require('./trusted-preview-resolver/workers/preview/deploy/resolve_preview_request.cjs');
|
|
await resolvePreviewRequest({ github, context, core });
|
|
|
|
deployment_record:
|
|
needs: preview-gate
|
|
if: needs.preview-gate.outputs.should_deploy == 'true'
|
|
name: Create GitHub Deployment
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
permissions:
|
|
contents: read
|
|
deployments: write
|
|
outputs:
|
|
deployment_id: ${{ steps.deployment.outputs.result }}
|
|
env:
|
|
HEAD_SHA: ${{ needs.preview-gate.outputs.head_sha }}
|
|
IS_FORK: ${{ needs.preview-gate.outputs.is_fork }}
|
|
PR_NUMBER: ${{ needs.preview-gate.outputs.pr_number }}
|
|
|
|
steps:
|
|
- name: Create GitHub Deployment
|
|
if: env.IS_FORK == 'false'
|
|
id: deployment
|
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
|
with:
|
|
script: |
|
|
const prNumber = process.env.PR_NUMBER;
|
|
const headSha = process.env.HEAD_SHA;
|
|
const deployment = await github.rest.repos.createDeployment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
ref: headSha,
|
|
environment: `preview-pr-${prNumber}`,
|
|
auto_merge: false,
|
|
required_contexts: [],
|
|
description: 'PR Preview Deployment'
|
|
});
|
|
return deployment.data.id;
|
|
result-encoding: string
|
|
|
|
deploy-preview:
|
|
needs: [preview-gate, deployment_record]
|
|
if: |
|
|
always() &&
|
|
needs.preview-gate.outputs.should_deploy == 'true' &&
|
|
(needs.deployment_record.result == 'success' || needs.deployment_record.result == 'skipped')
|
|
name: Deploy to Cloudflare Containers
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 45
|
|
concurrency:
|
|
group: preview-deploy-${{ needs.preview-gate.outputs.pr_number }}
|
|
cancel-in-progress: true
|
|
environment: preview
|
|
permissions:
|
|
actions: read
|
|
contents: read
|
|
outputs:
|
|
preview_url: ${{ steps.deploy.outputs.preview_url }}
|
|
env:
|
|
ARTIFACT_NAME: ${{ needs.preview-gate.outputs.artifact_name }}
|
|
HEAD_SHA: ${{ needs.preview-gate.outputs.head_sha }}
|
|
IS_FORK: ${{ needs.preview-gate.outputs.is_fork }}
|
|
PR_NUMBER: ${{ needs.preview-gate.outputs.pr_number }}
|
|
RESOLUTION_SOURCE: ${{ needs.preview-gate.outputs.resolution_source }}
|
|
|
|
steps:
|
|
- name: Checkout trusted preview tooling
|
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
with:
|
|
ref: ${{ github.event.repository.default_branch }}
|
|
path: trusted
|
|
persist-credentials: false
|
|
sparse-checkout: |
|
|
workers/preview
|
|
|
|
- name: Download preview image artifact
|
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
|
|
with:
|
|
name: ${{ env.ARTIFACT_NAME }}
|
|
run-id: ${{ github.event.workflow_run.id }}
|
|
github-token: ${{ github.token }}
|
|
path: ${{ runner.temp }}/preview-image
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
|
with:
|
|
node-version: "24"
|
|
|
|
- name: Verify preview image artifact checksum
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
image_archive="$RUNNER_TEMP/preview-image/sure-preview-image.tar.gz"
|
|
checksum_file="$RUNNER_TEMP/preview-image/sure-preview-image.sha256"
|
|
manifest_file="$RUNNER_TEMP/preview-image/sure-preview-image.manifest.json"
|
|
expected_files="$(mktemp)"
|
|
actual_files="$(mktemp)"
|
|
|
|
test -f "$image_archive"
|
|
test -f "$checksum_file"
|
|
test -f "$manifest_file"
|
|
|
|
printf '%s\n' \
|
|
sure-preview-image.manifest.json \
|
|
sure-preview-image.sha256 \
|
|
sure-preview-image.tar.gz | sort > "$expected_files"
|
|
find "$RUNNER_TEMP/preview-image" -maxdepth 1 -type f -printf '%f\n' | sort > "$actual_files"
|
|
|
|
if ! diff -u "$expected_files" "$actual_files"; then
|
|
echo "Preview image artifact contained unexpected files" >&2
|
|
exit 1
|
|
fi
|
|
|
|
expected_checksum="$(tr -d '[:space:]' < "$checksum_file")"
|
|
actual_checksum="$(sha256sum "$image_archive" | awk '{print $1}')"
|
|
|
|
if [ "$expected_checksum" != "$actual_checksum" ]; then
|
|
echo "Preview image artifact checksum mismatch" >&2
|
|
exit 1
|
|
fi
|
|
|
|
node - "$manifest_file" "$expected_checksum" <<'NODE'
|
|
const fs = require('node:fs');
|
|
|
|
const manifestPath = process.argv[2];
|
|
const expectedChecksum = process.argv[3];
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
const expectedImageTag = `sure-preview-pr-${process.env.PR_NUMBER}:${process.env.HEAD_SHA}`;
|
|
const expected = {
|
|
artifactVersion: 1,
|
|
archivePath: 'sure-preview-image.tar.gz',
|
|
archiveSha256: expectedChecksum,
|
|
headSha: process.env.HEAD_SHA,
|
|
imageTag: expectedImageTag,
|
|
prNumber: process.env.PR_NUMBER,
|
|
};
|
|
|
|
for (const [key, value] of Object.entries(expected)) {
|
|
if (manifest[key] !== value) {
|
|
throw new Error(`Preview image manifest ${key} mismatch`);
|
|
}
|
|
}
|
|
|
|
if (!/^sha256:[a-f0-9]{64}$/.test(manifest.imageId || '')) {
|
|
throw new Error('Preview image manifest imageId is invalid');
|
|
}
|
|
NODE
|
|
|
|
- name: Prepare trusted preview deploy workspace
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
preview_dir="$RUNNER_TEMP/sure-preview-worker"
|
|
rm -rf "$preview_dir"
|
|
mkdir -p "$preview_dir"
|
|
|
|
cp trusted/workers/preview/package.json "$preview_dir/package.json"
|
|
cp trusted/workers/preview/package-lock.json "$preview_dir/package-lock.json"
|
|
cp trusted/workers/preview/tsconfig.json "$preview_dir/tsconfig.json"
|
|
cp trusted/workers/preview/wrangler.toml "$preview_dir/wrangler.toml"
|
|
cp -R trusted/workers/preview/src "$preview_dir/src"
|
|
mkdir -p "$preview_dir/deploy"
|
|
cp trusted/workers/preview/deploy/redact_preview_log.sh "$preview_dir/deploy/redact_preview_log.sh"
|
|
cp trusted/workers/preview/deploy/render_preview_config.cjs "$preview_dir/deploy/render_preview_config.cjs"
|
|
chmod 0755 "$preview_dir/deploy/redact_preview_log.sh"
|
|
|
|
diagnostics_nonce="$(openssl rand -hex 32)"
|
|
sed -i "s/\${PR_NUMBER}/${PR_NUMBER}/g" "$preview_dir/wrangler.toml"
|
|
sed -i "s/\${PR_NUMBER}/${PR_NUMBER}/g" "$preview_dir/src/index.ts"
|
|
sed -i "s/\${PREVIEW_DIAGNOSTICS_NONCE}/${diagnostics_nonce}/g" "$preview_dir/src/index.ts"
|
|
cp "$preview_dir/wrangler.toml" "$preview_dir/wrangler.source.toml"
|
|
|
|
if grep -F "\${PREVIEW_DIAGNOSTICS_NONCE}" "$preview_dir/src/index.ts" >/dev/null; then
|
|
echo "Preview diagnostics nonce placeholder was not replaced" >&2
|
|
exit 1
|
|
fi
|
|
|
|
cd "$preview_dir"
|
|
npm ci --ignore-scripts --no-audit --no-fund
|
|
|
|
- name: Load preview image artifact
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
image_archive="$RUNNER_TEMP/preview-image/sure-preview-image.tar.gz"
|
|
manifest_file="$RUNNER_TEMP/preview-image/sure-preview-image.manifest.json"
|
|
expected_image="sure-preview-pr-${PR_NUMBER}:${HEAD_SHA}"
|
|
|
|
gzip -dc "$image_archive" | docker load
|
|
docker image inspect "$expected_image" >/dev/null
|
|
expected_image_id="$(node -e 'const fs = require("node:fs"); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1], "utf8")).imageId);' "$manifest_file")"
|
|
actual_image_id="$(docker image inspect --format '{{.Id}}' "$expected_image")"
|
|
|
|
if [ "$expected_image_id" != "$actual_image_id" ]; then
|
|
echo "Loaded preview image ID did not match artifact manifest" >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Push preview image to Cloudflare registry
|
|
id: image
|
|
env:
|
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN || secrets.CLOUDFLARE_API_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
cd "$RUNNER_TEMP/sure-preview-worker"
|
|
source_config="$RUNNER_TEMP/sure-preview-worker/wrangler.source.toml"
|
|
config_path="$RUNNER_TEMP/sure-preview-worker/wrangler.toml"
|
|
image_tag="sure-preview-pr-${PR_NUMBER}:${HEAD_SHA}"
|
|
temporary_image_ref="registry.cloudflare.com/${CLOUDFLARE_ACCOUNT_ID}/${image_tag}"
|
|
push_log="$RUNNER_TEMP/wrangler-containers-push.log"
|
|
clean_log="$RUNNER_TEMP/wrangler-containers-push.clean.log"
|
|
push_status=0
|
|
|
|
# wrangler containers push validates wrangler.toml, so point the trusted
|
|
# config at a registry-shaped ref while it pushes the verified local image.
|
|
PREVIEW_IMAGE_REF="$temporary_image_ref" node ./deploy/render_preview_config.cjs render "$source_config" "$config_path"
|
|
cp "$config_path" "$RUNNER_TEMP/wrangler-push.toml"
|
|
|
|
set +e
|
|
./node_modules/.bin/wrangler containers push "$image_tag" 2>&1 | tee "$push_log" | ./deploy/redact_preview_log.sh
|
|
push_status=${PIPESTATUS[0]}
|
|
set -e
|
|
perl -pe 's/\e\[[0-9;]*[A-Za-z]//g' "$push_log" > "$clean_log"
|
|
|
|
if [ "$push_status" -ne 0 ]; then
|
|
exit "$push_status"
|
|
fi
|
|
|
|
image_ref="$(node ./deploy/render_preview_config.cjs find "$clean_log")"
|
|
|
|
if [ -z "$image_ref" ]; then
|
|
echo "Could not find Cloudflare registry image reference in wrangler output" >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "image_ref=${image_ref}" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Configure trusted preview image reference
|
|
env:
|
|
IMAGE_REF: ${{ steps.image.outputs.image_ref }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
source_config="$RUNNER_TEMP/sure-preview-worker/wrangler.source.toml"
|
|
config_path="$RUNNER_TEMP/sure-preview-worker/wrangler.toml"
|
|
# Render from the preserved trusted source template so the push-time
|
|
# registry ref cannot make the final deploy rewrite stateful.
|
|
PREVIEW_IMAGE_REF="$IMAGE_REF" node "$RUNNER_TEMP/sure-preview-worker/deploy/render_preview_config.cjs" render "$source_config" "$config_path"
|
|
cp "$config_path" "$RUNNER_TEMP/wrangler-final.toml"
|
|
|
|
# Print a redacted copy for logs without mutating the config used by deploy.
|
|
redacted_config="$RUNNER_TEMP/wrangler-redacted.toml"
|
|
"$RUNNER_TEMP/sure-preview-worker/deploy/redact_preview_log.sh" < "$config_path" > "$redacted_config"
|
|
cat "$redacted_config"
|
|
|
|
- name: Deploy to Cloudflare Containers
|
|
id: deploy
|
|
env:
|
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN || secrets.CLOUDFLARE_API_TOKEN }}
|
|
CLOUDFLARE_WORKERS_SUBDOMAIN: ${{ secrets.CLOUDFLARE_WORKERS_SUBDOMAIN }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
cd "$RUNNER_TEMP/sure-preview-worker"
|
|
deploy_log="$RUNNER_TEMP/wrangler-deploy.log"
|
|
clean_deploy_log="$RUNNER_TEMP/wrangler-deploy.clean.log"
|
|
|
|
deploy_once() {
|
|
set +e
|
|
./node_modules/.bin/wrangler deploy --config wrangler.toml --var "PR_NUMBER:${PR_NUMBER}" 2>&1 | tee "$deploy_log" | ./deploy/redact_preview_log.sh
|
|
local deploy_status=${PIPESTATUS[0]}
|
|
set -e
|
|
return "$deploy_status"
|
|
}
|
|
|
|
if ! deploy_once; then
|
|
perl -pe 's/\e\[[0-9;]*[A-Za-z]//g' "$deploy_log" > "$clean_deploy_log"
|
|
|
|
if grep -F "associated with a different durable object namespace" "$clean_deploy_log" >/dev/null; then
|
|
echo "Detected stale Cloudflare container app state for PR ${PR_NUMBER}; deleting preview Worker and retrying once."
|
|
if ! ./node_modules/.bin/wrangler delete --name "sure-preview-${PR_NUMBER}" --force; then
|
|
echo "Preview Worker delete failed; continuing to the single retry so wrangler deploy reports the final error if the stale state remains." >&2
|
|
fi
|
|
deploy_once
|
|
else
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# Get the deployment URL
|
|
PREVIEW_URL="https://sure-preview-${PR_NUMBER}.${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 --connect-timeout 5 --max-time 15 "$PREVIEW_URL/_container_status" >/dev/null || true
|
|
|
|
- name: Collect preview diagnostics
|
|
if: success()
|
|
env:
|
|
PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
diagnostics_dir="$RUNNER_TEMP/preview-diagnostics"
|
|
diagnostics_file="$diagnostics_dir/preview-diagnostics.json"
|
|
latest_metrics_file="$diagnostics_dir/latest-metrics.json"
|
|
polls_log="$diagnostics_dir/metrics-polls.log"
|
|
summary_file="$diagnostics_dir/summary.md"
|
|
last_error=""
|
|
mkdir -p "$diagnostics_dir"
|
|
|
|
# ~100 polls x ~3.5s = up to ~6 min. The preview must cold-start the
|
|
# container (≈1.3 GB image pull + sandbox init) and generate the full
|
|
# demo dataset before previewReady flips. A real Cloudflare standard-1
|
|
# run measured previewReady at ~195s (rails ~46s, demo data ~149s) —
|
|
# the old 40-poll (~140s) budget would have failed a working preview.
|
|
# Keep generous headroom; the loop still breaks early on ready/failed.
|
|
for attempt in $(seq 1 100); do
|
|
if curl -fsS --connect-timeout 5 --max-time 15 "$PREVIEW_URL/_container_status" -o "$diagnostics_file"; then
|
|
if jq -e . "$diagnostics_file" >/dev/null 2>&1; then
|
|
jq -c --argjson attempt "$attempt" --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
'{attempt: $attempt, at: $at, previewReady: (.previewReady // false), previewFailed: (.previewFailed // false), progress: (.progress // {}), timings: (.timings // {})}' \
|
|
"$diagnostics_file" >> "$polls_log"
|
|
jq '{previewReady: (.previewReady // false), previewFailed: (.previewFailed // false), progress: (.progress // {}), timings: (.timings // {})}' "$diagnostics_file" > "$latest_metrics_file"
|
|
|
|
if jq -e '.previewReady == true or .previewFailed == true' "$diagnostics_file" >/dev/null; then
|
|
break
|
|
fi
|
|
else
|
|
last_error="invalid diagnostics JSON on attempt ${attempt}"
|
|
raw_snippet="$(head -c 2048 "$diagnostics_file")"
|
|
latest_metrics_snapshot="none"
|
|
if [ -f "$latest_metrics_file" ]; then
|
|
latest_metrics_snapshot="$(head -c 2048 "$latest_metrics_file")"
|
|
fi
|
|
jq -nc --argjson attempt "$attempt" --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg error "$last_error" --arg latestMetrics "$latest_metrics_snapshot" --arg rawSnippet "$raw_snippet" \
|
|
'{attempt: $attempt, at: $at, error: $error, latestMetrics: $latestMetrics, rawSnippet: $rawSnippet}' >> "$polls_log"
|
|
fi
|
|
else
|
|
last_error="curl failed on attempt ${attempt}"
|
|
jq -nc --argjson attempt "$attempt" --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg error "$last_error" \
|
|
'{attempt: $attempt, at: $at, error: $error}' >> "$polls_log"
|
|
fi
|
|
|
|
sleep 3
|
|
done
|
|
|
|
if [ ! -s "$diagnostics_file" ] || ! jq -e . "$diagnostics_file" >/dev/null 2>&1; then
|
|
jq -n --arg error "${last_error:-preview diagnostics unavailable}" \
|
|
--arg url "$PREVIEW_URL" \
|
|
'{previewReady: false, previewFailed: false, error: $error, previewUrl: $url}' > "$diagnostics_file"
|
|
fi
|
|
|
|
jq '{previewReady: (.previewReady // false), previewFailed: (.previewFailed // false), progress: (.progress // {}), timings: (.timings // {}), error: (.error // null)}' "$diagnostics_file" > "$latest_metrics_file"
|
|
{
|
|
echo "# Preview diagnostics"
|
|
echo
|
|
echo "- PR: ${PR_NUMBER}"
|
|
echo "- Commit: ${HEAD_SHA}"
|
|
echo "- Preview URL: ${PREVIEW_URL}"
|
|
echo "- Preview ready: $(jq -r '.previewReady // false' "$diagnostics_file")"
|
|
echo "- Preview failed: $(jq -r '.previewFailed // false' "$diagnostics_file")"
|
|
echo "- Phase: $(jq -r '.progress.phase // "unknown"' "$diagnostics_file")"
|
|
echo "- Stage: $(jq -r '.progress.stage // "unknown"' "$diagnostics_file")"
|
|
echo "- Seconds to Rails ready: $(jq -r '.timings.secondsToRailsReady // "unknown"' "$diagnostics_file")"
|
|
echo "- Seconds to demo data ready: $(jq -r '.timings.secondsToDemoDataReady // "unknown"' "$diagnostics_file")"
|
|
echo "- Seconds to preview ready: $(jq -r '.timings.secondsToPreviewReady // "unknown"' "$diagnostics_file")"
|
|
} > "$summary_file"
|
|
|
|
jq -c . "$diagnostics_file"
|
|
|
|
if jq -e '.previewFailed == true' "$diagnostics_file" >/dev/null; then
|
|
echo "Preview diagnostics from _container_status reported previewFailed=true:" >&2
|
|
jq -c . "$diagnostics_file" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if ! jq -e '.previewReady == true' "$diagnostics_file" >/dev/null; then
|
|
echo "Preview diagnostics from _container_status did not reach previewReady=true:" >&2
|
|
jq -c . "$diagnostics_file" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if ! jq -e '.timings.previewReadyAt != null and .timings.secondsToPreviewReady != null' "$diagnostics_file" >/dev/null; then
|
|
echo "Preview diagnostics are missing readiness timing fields:" >&2
|
|
jq -c . "$diagnostics_file" >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Upload preview diagnostics
|
|
if: always() && steps.deploy.outputs.preview_url != ''
|
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
|
with:
|
|
name: preview-diagnostics-pr-${{ env.PR_NUMBER }}-${{ env.HEAD_SHA }}
|
|
path: ${{ runner.temp }}/preview-diagnostics
|
|
if-no-files-found: error
|
|
retention-days: 3
|
|
|
|
- name: Collect preview failure diagnostics
|
|
if: failure()
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
diagnostics_dir="$RUNNER_TEMP/preview-failure-diagnostics"
|
|
manifest_file="$RUNNER_TEMP/preview-image/sure-preview-image.manifest.json"
|
|
redaction_helper="$RUNNER_TEMP/sure-preview-worker/deploy/redact_preview_log.sh"
|
|
mkdir -p "$diagnostics_dir"
|
|
|
|
jq -n \
|
|
--arg artifactName "$ARTIFACT_NAME" \
|
|
--arg headSha "$HEAD_SHA" \
|
|
--arg isFork "$IS_FORK" \
|
|
--arg prNumber "$PR_NUMBER" \
|
|
--arg resolutionSource "$RESOLUTION_SOURCE" \
|
|
'{
|
|
artifactName: $artifactName,
|
|
headSha: $headSha,
|
|
isFork: $isFork,
|
|
prNumber: $prNumber,
|
|
resolutionSource: $resolutionSource
|
|
}' > "$diagnostics_dir/preview-request.json"
|
|
|
|
sanitize_copy() {
|
|
local source="$1"
|
|
local destination="$2"
|
|
if [ -f "$source" ]; then
|
|
if [ -x "$redaction_helper" ]; then
|
|
"$redaction_helper" < "$source" > "$destination"
|
|
else
|
|
cp "$source" "$destination"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
if [ -f "$manifest_file" ]; then
|
|
jq '{
|
|
artifactVersion,
|
|
archivePath,
|
|
archiveSha256,
|
|
headSha,
|
|
imageId,
|
|
imageTag,
|
|
prNumber
|
|
}' "$manifest_file" > "$diagnostics_dir/preview-image-manifest.json"
|
|
fi
|
|
|
|
sanitize_copy "$RUNNER_TEMP/sure-preview-worker/wrangler.source.toml" "$diagnostics_dir/wrangler-source.toml"
|
|
sanitize_copy "$RUNNER_TEMP/wrangler-push.toml" "$diagnostics_dir/wrangler-push.toml"
|
|
sanitize_copy "$RUNNER_TEMP/wrangler-final.toml" "$diagnostics_dir/wrangler-final.toml"
|
|
sanitize_copy "$RUNNER_TEMP/sure-preview-worker/wrangler.toml" "$diagnostics_dir/wrangler.toml"
|
|
sanitize_copy "$RUNNER_TEMP/wrangler-containers-push.clean.log" "$diagnostics_dir/wrangler-containers-push.log"
|
|
if [ -f "$RUNNER_TEMP/wrangler-deploy.clean.log" ]; then
|
|
sanitize_copy "$RUNNER_TEMP/wrangler-deploy.clean.log" "$diagnostics_dir/wrangler-deploy.log"
|
|
else
|
|
sanitize_copy "$RUNNER_TEMP/wrangler-deploy.log" "$diagnostics_dir/wrangler-deploy.log"
|
|
fi
|
|
|
|
find "$diagnostics_dir" -maxdepth 1 -type f -print
|
|
|
|
- name: Upload preview failure diagnostics
|
|
if: failure()
|
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
|
with:
|
|
name: preview-failure-diagnostics-pr-${{ env.PR_NUMBER }}-${{ env.HEAD_SHA }}
|
|
path: ${{ runner.temp }}/preview-failure-diagnostics
|
|
if-no-files-found: error
|
|
retention-days: 3
|
|
|
|
- name: Prepare cleanup metadata
|
|
if: success()
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
metadata_dir="$RUNNER_TEMP/preview-cleanup-metadata"
|
|
mkdir -p "$metadata_dir"
|
|
"$RUNNER_TEMP/sure-preview-worker/deploy/redact_preview_log.sh" \
|
|
< "$RUNNER_TEMP/sure-preview-worker/wrangler.toml" \
|
|
> "$metadata_dir/wrangler.toml"
|
|
|
|
- name: Store cleanup metadata
|
|
if: success()
|
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
|
with:
|
|
name: preview-cleanup-pr-${{ env.PR_NUMBER }}
|
|
path: ${{ runner.temp }}/preview-cleanup-metadata/wrangler.toml
|
|
retention-days: 2
|
|
|
|
deployment_status:
|
|
needs: [preview-gate, deployment_record, deploy-preview]
|
|
if: |
|
|
always() &&
|
|
needs.preview-gate.outputs.should_deploy == 'true' &&
|
|
needs.preview-gate.outputs.is_fork == 'false' &&
|
|
needs.deployment_record.result == 'success'
|
|
name: Update GitHub Deployment Status
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
permissions:
|
|
contents: read
|
|
deployments: write
|
|
env:
|
|
DEPLOYMENT_ID: ${{ needs.deployment_record.outputs.deployment_id }}
|
|
DEPLOY_RESULT: ${{ needs.deploy-preview.result }}
|
|
PREVIEW_URL: ${{ needs.deploy-preview.outputs.preview_url }}
|
|
|
|
steps:
|
|
- name: Update Deployment Status
|
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
|
with:
|
|
script: |
|
|
const state = process.env.DEPLOY_RESULT === 'success' ? 'success' : 'failure';
|
|
const previewUrl = process.env.PREVIEW_URL || undefined;
|
|
await github.rest.repos.createDeploymentStatus({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
deployment_id: Number(process.env.DEPLOYMENT_ID),
|
|
state: state,
|
|
environment_url: state === 'success' ? previewUrl : undefined,
|
|
description: state === 'success' ? 'Preview deployed successfully' : 'Preview deployment failed'
|
|
});
|
|
|
|
preview_comment:
|
|
needs: [preview-gate, deploy-preview]
|
|
if: needs.deploy-preview.result == 'success'
|
|
name: Comment on PR
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
permissions:
|
|
contents: read
|
|
pull-requests: write
|
|
env:
|
|
HEAD_SHA: ${{ needs.preview-gate.outputs.head_sha }}
|
|
PR_NUMBER: ${{ needs.preview-gate.outputs.pr_number }}
|
|
PREVIEW_URL: ${{ needs.deploy-preview.outputs.preview_url }}
|
|
|
|
steps:
|
|
- name: Comment on PR
|
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
|
with:
|
|
script: |
|
|
const previewUrl = process.env.PREVIEW_URL;
|
|
const issueNumber = Number(process.env.PR_NUMBER);
|
|
const headSha = process.env.HEAD_SHA;
|
|
const commentBody = [
|
|
'## 🚀 Preview Deployment Ready',
|
|
'',
|
|
"Your preview environment has been deployed to Cloudflare Containers with the PR's Docker image.",
|
|
'',
|
|
`**Preview URL:** ${previewUrl}`,
|
|
'',
|
|
'> ⏰ This preview is intended to be cleaned up after **24 hours** of the last deployment once the cleanup workflow is live on the default branch.',
|
|
'> 💤 The container will sleep after 30 minutes of inactivity and wake on the next request.',
|
|
'',
|
|
'---',
|
|
`<sub>Deployed from commit ${headSha}</sub>`,
|
|
].join('\n');
|
|
|
|
// Find existing comment
|
|
const { data: comments } = await github.rest.issues.listComments({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: issueNumber
|
|
});
|
|
|
|
const botComment = comments.find(comment =>
|
|
comment.user.type === 'Bot' &&
|
|
comment.body.includes('Preview Deployment Ready')
|
|
);
|
|
|
|
if (botComment) {
|
|
await github.rest.issues.updateComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
comment_id: botComment.id,
|
|
body: commentBody
|
|
});
|
|
} else {
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: issueNumber,
|
|
body: commentBody
|
|
});
|
|
}
|