Files
sure/.github/workflows/preview-deploy.yml
ghost 1af880aa2a ci(preview): stabilize image push and readiness diagnostics (#2084)
* ci(preview): rewrite image config before registry push

Point the trusted preview deploy config at the loaded CI image before Wrangler validates the worker config for the Cloudflare registry push. This keeps the existing trusted deploy boundary intact while fixing the post-2062 image-push ordering regression.

* ci(preview): require trusted readiness diagnostics

* ci(preview): use nonce for diagnostics events

* ci(preview): retain diagnostics timing anchors
2026-06-01 10:51:29 +02:00

514 lines
20 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 }}
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@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
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@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
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 }}
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"
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"
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"
config_path="$RUNNER_TEMP/sure-preview-worker/wrangler.toml"
image_tag="sure-preview-pr-${PR_NUMBER}:${HEAD_SHA}"
push_log="$RUNNER_TEMP/wrangler-containers-push.log"
clean_log="$RUNNER_TEMP/wrangler-containers-push.clean.log"
# wrangler containers push validates wrangler.toml, so point the trusted
# config at the loaded CI image before replacing it with the registry ref.
LOCAL_IMAGE_TAG="$image_tag" node - "$config_path" <<'NODE'
const fs = require('node:fs');
const configPath = process.argv[2];
const imageTag = process.env.LOCAL_IMAGE_TAG;
if (!/^sure-preview-pr-[1-9][0-9]*:[a-f0-9]{40}$/.test(imageTag || '')) {
throw new Error('Expected local preview image tag for wrangler containers push');
}
const original = fs.readFileSync(configPath, 'utf8');
const updated = original.replace(/image = "[^"]+"/, `image = ${JSON.stringify(imageTag)}`);
if (updated === original) {
throw new Error('Expected wrangler.toml to contain an image entry to rewrite before push');
}
fs.writeFileSync(configPath, updated);
NODE
./node_modules/.bin/wrangler containers push "$image_tag" 2>&1 | tee "$push_log"
perl -pe 's/\e\[[0-9;]*[A-Za-z]//g' "$push_log" > "$clean_log"
image_ref=$(grep -Eo 'registry\.cloudflare\.com/[^[:space:]]+' "$clean_log" | tail -n 1 | tr -d '\r')
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
config_path="$RUNNER_TEMP/sure-preview-worker/wrangler.toml"
# Use Node instead of sed so the replacement preserves TOML string syntax.
node - "$config_path" <<'NODE'
const fs = require('node:fs');
const configPath = process.argv[2];
const imageRef = process.env.IMAGE_REF;
if (!imageRef || !imageRef.startsWith('registry.cloudflare.com/')) {
throw new Error('Expected a Cloudflare registry image reference');
}
const original = fs.readFileSync(configPath, 'utf8');
const updated = original.replace(/image = "[^"]+"/, `image = ${JSON.stringify(imageRef)}`);
if (updated === original) {
throw new Error('Expected wrangler.toml to contain an image entry to rewrite');
}
fs.writeFileSync(configPath, updated);
NODE
cat "$config_path"
- 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() {
./node_modules/.bin/wrangler deploy --config wrangler.toml --var "PR_NUMBER:${PR_NUMBER}" 2>&1 | tee "$deploy_log"
}
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_file="$RUNNER_TEMP/preview-diagnostics.json"
last_error=""
for attempt in $(seq 1 40); do
if curl -fsS --connect-timeout 5 --max-time 15 "$PREVIEW_URL/_container_status" -o "$diagnostics_file"; then
if jq -e '.previewReady == true or .previewFailed == true' "$diagnostics_file" >/dev/null; then
break
fi
else
last_error="curl failed on attempt ${attempt}"
fi
sleep 3
done
if [ ! -s "$diagnostics_file" ]; 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 -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.json
if-no-files-found: error
retention-days: 3
- name: Store cleanup metadata
if: success()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: preview-cleanup-pr-${{ env.PR_NUMBER }}
path: ${{ runner.temp }}/sure-preview-worker/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@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
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@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
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
});
}