Files
sure/.github/workflows/preview-deploy.yml
ghost f7c633ef20 fix(preview): bind :3000 instantly and bound diagnostics posts (#2286)
* 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.
2026-06-12 10:40:35 +02:00

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
});
}