diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index f07589510..edd1e2f6e 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -38,6 +38,7 @@ jobs:
set -euo pipefail
image_archive="$RUNNER_TEMP/sure-preview-image.tar.gz"
+ manifest_file="$RUNNER_TEMP/sure-preview-image.manifest.json"
docker build \
--platform linux/amd64 \
@@ -48,7 +49,27 @@ jobs:
docker image inspect "${IMAGE_TAG}" >/dev/null
docker save "${IMAGE_TAG}" | gzip -1 > "$image_archive"
- sha256sum "$image_archive" | awk '{print $1}' > "$RUNNER_TEMP/sure-preview-image.sha256"
+ archive_sha256="$(sha256sum "$image_archive" | awk '{print $1}')"
+ image_id="$(docker image inspect --format '{{.Id}}' "${IMAGE_TAG}")"
+
+ printf '%s\n' "$archive_sha256" > "$RUNNER_TEMP/sure-preview-image.sha256"
+ ARCHIVE_SHA256="$archive_sha256" IMAGE_ID="$image_id" node - "$manifest_file" <<'NODE'
+ const fs = require('node:fs');
+
+ const manifestPath = process.argv[2];
+ const manifest = {
+ artifactVersion: 1,
+ archivePath: 'sure-preview-image.tar.gz',
+ archiveSha256: process.env.ARCHIVE_SHA256,
+ headSha: process.env.HEAD_SHA,
+ imageId: process.env.IMAGE_ID,
+ imageTag: process.env.IMAGE_TAG,
+ prNumber: process.env.PR_NUMBER,
+ };
+
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
+ NODE
+ jq -e . "$manifest_file" >/dev/null
- name: Upload preview image artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
@@ -57,5 +78,6 @@ jobs:
path: |
${{ runner.temp }}/sure-preview-image.tar.gz
${{ runner.temp }}/sure-preview-image.sha256
+ ${{ runner.temp }}/sure-preview-image.manifest.json
if-no-files-found: error
retention-days: 3
diff --git a/.github/workflows/preview-cleanup.yml b/.github/workflows/preview-cleanup.yml
index 8d09aeff5..60f21c8d9 100644
--- a/.github/workflows/preview-cleanup.yml
+++ b/.github/workflows/preview-cleanup.yml
@@ -195,7 +195,7 @@ jobs:
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-//')
+ PR_NUM="${WORKER#sure-preview-}"
if [[ "$PR_NUM" =~ ^[1-9][0-9]*$ ]]; then
echo "Cleaning up GitHub deployment for PR #$PR_NUM"
gh api \
diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml
index 01d486cff..76fd9ee56 100644
--- a/.github/workflows/preview-deploy.yml
+++ b/.github/workflows/preview-deploy.yml
@@ -23,82 +23,71 @@ jobs:
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 workflowRun = context.payload.workflow_run;
- const runPr = workflowRun.pull_requests?.[0];
+ const { resolvePreviewRequest } = require('./trusted-preview-resolver/workers/preview/deploy/resolve_preview_request.cjs');
+ await resolvePreviewRequest({ github, context, core });
- core.setOutput('should_deploy', 'false');
-
- if (!runPr) {
- core.info('Workflow run is not associated with a pull request');
- return;
- }
-
- const prNumber = runPr.number;
- const headSha = workflowRun.head_sha;
-
- const { data: pullRequest } = await github.rest.pulls.get({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: prNumber,
- });
-
- if (pullRequest.head.sha !== headSha) {
- core.setFailed(`Workflow run head SHA ${headSha} does not match PR head ${pullRequest.head.sha}`);
- return;
- }
-
- const hasPreviewLabel = pullRequest.labels.some((label) => label.name === 'preview-cf');
- if (!hasPreviewLabel) {
- core.info(`PR ${prNumber} does not have the preview-cf label`);
- return;
- }
-
- const files = await github.paginate(github.rest.pulls.listFiles, {
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: prNumber,
- per_page: 100,
- });
- const workflowChanges = files
- .map((file) => file.filename)
- .filter((filename) => filename.startsWith('.github/workflows/'));
-
- if (workflowChanges.length > 0) {
- core.setFailed(`Preview deployment requires base-trusted workflow definitions; changed workflow files: ${workflowChanges.join(', ')}`);
- return;
- }
-
- const artifactName = `preview-image-pr-${prNumber}-${headSha}`;
- const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
- owner: context.repo.owner,
- repo: context.repo.repo,
- run_id: workflowRun.id,
- per_page: 100,
- });
- const artifact = artifacts.find((item) => item.name === artifactName && !item.expired);
-
- if (!artifact) {
- core.setFailed(`Pull Request workflow run ${workflowRun.id} did not publish ${artifactName}`);
- return;
- }
-
- core.setOutput('artifact_name', artifactName);
- core.setOutput('head_sha', headSha);
- core.setOutput('pr_number', String(prNumber));
- core.setOutput('should_deploy', 'true');
-
- deploy-preview:
+ 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
@@ -109,11 +98,12 @@ jobs:
permissions:
actions: read
contents: read
- pull-requests: write
- deployments: write
+ 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:
@@ -134,15 +124,35 @@ jobs:
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}')"
@@ -152,10 +162,32 @@ jobs:
exit 1
fi
- - name: Setup Node.js
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
- with:
- node-version: "24"
+ 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: |
@@ -182,10 +214,18 @@ jobs:
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
@@ -239,25 +279,6 @@ jobs:
cat "$config_path"
- - name: Create GitHub Deployment
- 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
-
- name: Deploy to Cloudflare Containers
id: deploy
env:
@@ -268,7 +289,26 @@ jobs:
set -euo pipefail
cd "$RUNNER_TEMP/sure-preview-worker"
- ./node_modules/.bin/wrangler deploy --config wrangler.toml --var "PR_NUMBER:${PR_NUMBER}"
+ 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"
@@ -279,17 +319,85 @@ jobs:
PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }}
run: |
echo "Triggering preview wake-up..."
- curl -fsS "$PREVIEW_URL/" >/dev/null || true
+ curl -fsS --connect-timeout 5 --max-time 15 "$PREVIEW_URL/_container_status" >/dev/null || true
- - name: Update Deployment Status
- if: always() && steps.deployment.outputs.result
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
+ - name: Collect preview diagnostics
+ if: success()
env:
- DEPLOYMENT_ID: ${{ steps.deployment.outputs.result }}
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 20); 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
+
+ - name: Upload preview diagnostics
+ if: success()
+ 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 = '${{ job.status }}' === 'success' ? 'success' : 'failure';
+ 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,
@@ -300,27 +408,41 @@ jobs:
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
- if: success()
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
- env:
- PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }}
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.
-
- ---
- Deployed from commit ${headSha}`;
+ 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.',
+ '',
+ '---',
+ `Deployed from commit ${headSha}`,
+ ].join('\n');
// Find existing comment
const { data: comments } = await github.rest.issues.listComments({
@@ -349,11 +471,3 @@ jobs:
body: commentBody
});
}
-
- - 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
diff --git a/bin/preview_deploy_security_check.rb b/bin/preview_deploy_security_check.rb
index f4d5f1a60..877300637 100644
--- a/bin/preview_deploy_security_check.rb
+++ b/bin/preview_deploy_security_check.rb
@@ -7,6 +7,7 @@ ROOT = File.expand_path("..", __dir__)
PREVIEW_WORKFLOW_PATH = File.join(ROOT, ".github/workflows/preview-deploy.yml")
PR_WORKFLOW_PATH = File.join(ROOT, ".github/workflows/pr.yml")
LOCKFILE_PATH = File.join(ROOT, "workers/preview/package-lock.json")
+RESOLVER_PATH = File.join(ROOT, "workers/preview/deploy/resolve_preview_request.cjs")
PINNED_ACTION = /\A[^@\s]+@[a-f0-9]{40}\z/
EXPECTED_ACTION_PINS = {
"actions/checkout" => "93cb6efe18208431cddfb8368fd83d5badbf9bfd", # v5
@@ -34,10 +35,16 @@ GITHUB_WORKSPACE_PREFIX = %r{
EXPECTED_TOP_LEVEL_PERMISSIONS = { "contents" => "read" }.freeze
EXPECTED_GATE_PERMISSIONS = { "actions" => "read", "contents" => "read", "pull-requests" => "read" }.freeze
EXPECTED_IMAGE_PERMISSIONS = { "contents" => "read" }.freeze
+EXPECTED_DEPLOYMENT_PERMISSIONS = {
+ "contents" => "read",
+ "deployments" => "write"
+}.freeze
EXPECTED_DEPLOY_PERMISSIONS = {
"actions" => "read",
+ "contents" => "read"
+}.freeze
+EXPECTED_COMMENT_PERMISSIONS = {
"contents" => "read",
- "deployments" => "write",
"pull-requests" => "write"
}.freeze
EXPECTED_DEPLOY_SECRET_ENV = %w[CLOUDFLARE_ACCOUNT_ID CLOUDFLARE_API_TOKEN CLOUDFLARE_WORKERS_SUBDOMAIN].freeze
@@ -57,7 +64,11 @@ REQUIRED_IMAGE_BUILD_LINES = [
"-f Dockerfile.preview",
'-t "${IMAGE_TAG}"',
'docker save "${IMAGE_TAG}" | gzip -1 > "$image_archive"',
- 'sha256sum "$image_archive"'
+ 'sha256sum "$image_archive"',
+ "sure-preview-image.manifest.json",
+ 'ARCHIVE_SHA256="$archive_sha256" IMAGE_ID="$image_id" node - "$manifest_file"',
+ "JSON.stringify(manifest, null, 2)",
+ 'jq -e . "$manifest_file"'
].freeze
def fail_check(message)
@@ -137,6 +148,7 @@ end
preview_workflow = YAML.safe_load_file(PREVIEW_WORKFLOW_PATH, aliases: true)
pr_workflow = YAML.safe_load_file(PR_WORKFLOW_PATH, aliases: true)
lockfile = JSON.parse(File.read(LOCKFILE_PATH))
+resolver_script = File.read(RESOLVER_PATH)
preview_on = workflow_on(preview_workflow)
pr_on = workflow_on(pr_workflow)
@@ -144,19 +156,27 @@ preview_jobs = preview_workflow.fetch("jobs")
pr_jobs = pr_workflow.fetch("jobs")
gate_job = preview_jobs.fetch("preview-gate")
image_job = pr_jobs.fetch("preview_image")
+deployment_record_job = preview_jobs.fetch("deployment_record")
deploy_job = preview_jobs.fetch("deploy-preview")
+deployment_status_job = preview_jobs.fetch("deployment_status")
+preview_comment_job = preview_jobs.fetch("preview_comment")
gate_steps = gate_job.fetch("steps")
image_steps = image_job.fetch("steps")
+deployment_record_steps = deployment_record_job.fetch("steps")
deploy_steps = deploy_job.fetch("steps")
+deployment_status_steps = deployment_status_job.fetch("steps")
+preview_comment_steps = preview_comment_job.fetch("steps")
deploy_step_names = deploy_steps.map { |step| step["name"] }
wrangler = lockfile.fetch("packages").fetch("node_modules/wrangler")
+gate_trusted_checkout = step!(gate_steps, "Checkout trusted preview resolver")
resolve_preview = step!(gate_steps, "Resolve preview request")
pr_checkout = step!(image_steps, "Checkout PR code")
build_image = step!(image_steps, "Build preview image without secrets")
upload_image = step!(image_steps, "Upload preview image artifact")
+create_deployment = step!(deployment_record_steps, "Create GitHub Deployment")
trusted_checkout = step!(deploy_steps, "Checkout trusted preview tooling")
download_artifact = step!(deploy_steps, "Download preview image artifact")
verify_checksum = step!(deploy_steps, "Verify preview image artifact checksum")
@@ -165,13 +185,18 @@ load_image = step!(deploy_steps, "Load preview image artifact")
push_image = step!(deploy_steps, "Push preview image to Cloudflare registry")
configure_image = step!(deploy_steps, "Configure trusted preview image reference")
deploy = step!(deploy_steps, "Deploy to Cloudflare Containers")
+warm_preview = step!(deploy_steps, "Warm preview container")
+collect_diagnostics = step!(deploy_steps, "Collect preview diagnostics")
+upload_diagnostics = step!(deploy_steps, "Upload preview diagnostics")
+update_deployment_status = step!(deployment_status_steps, "Update Deployment Status")
+comment_on_pr = step!(preview_comment_steps, "Comment on PR")
[
[ "preview trigger", preview_on.keys, [ "workflow_run" ] ],
[ "preview trigger workflows", preview_on.dig("workflow_run", "workflows"), [ "Pull Request" ] ],
[ "preview trigger types", preview_on.dig("workflow_run", "types"), [ "completed" ] ],
[ "preview top-level permissions", preview_workflow.fetch("permissions"), EXPECTED_TOP_LEVEL_PERMISSIONS ],
- [ "preview workflow jobs", preview_jobs.keys, [ "preview-gate", "deploy-preview" ] ],
+ [ "preview workflow jobs", preview_jobs.keys, [ "preview-gate", "deployment_record", "deploy-preview", "deployment_status", "preview_comment" ] ],
[ "PR workflow trigger types", pr_on.dig("pull_request", "types"), %w[opened synchronize reopened labeled] ],
[ "PR workflow paths-ignore", pr_on.dig("pull_request", "paths-ignore"), [ "charts/**" ] ],
[ "PR workflow permissions", pr_workflow.fetch("permissions"), EXPECTED_TOP_LEVEL_PERMISSIONS ],
@@ -181,6 +206,7 @@ deploy = step!(deploy_steps, "Deploy to Cloudflare Containers")
[ "preview gate should_deploy output", gate_job.dig("outputs", "should_deploy"), "${{ steps.preview.outputs.should_deploy }}" ],
[ "preview gate artifact output", gate_job.dig("outputs", "artifact_name"), "${{ steps.preview.outputs.artifact_name }}" ],
[ "preview gate head output", gate_job.dig("outputs", "head_sha"), "${{ steps.preview.outputs.head_sha }}" ],
+ [ "preview gate fork output", gate_job.dig("outputs", "is_fork"), "${{ steps.preview.outputs.is_fork }}" ],
[ "preview gate PR output", gate_job.dig("outputs", "pr_number"), "${{ steps.preview.outputs.pr_number }}" ],
[ "preview image needs", image_job.fetch("needs"), "ci" ],
[ "preview image permissions", image_job.fetch("permissions"), EXPECTED_IMAGE_PERMISSIONS ],
@@ -188,15 +214,41 @@ deploy = step!(deploy_steps, "Deploy to Cloudflare Containers")
[ "image PR_NUMBER env", image_job.dig("env", "PR_NUMBER"), "${{ github.event.pull_request.number }}" ],
[ "image HEAD_SHA env", image_job.dig("env", "HEAD_SHA"), "${{ github.event.pull_request.head.sha }}" ],
[ "image tag env", image_job.dig("env", "IMAGE_TAG"), "sure-preview-pr-${{ github.event.pull_request.number }}:${{ github.event.pull_request.head.sha }}" ],
- [ "deploy job needs", deploy_job.fetch("needs"), "preview-gate" ],
+ [ "deployment record needs", deployment_record_job.fetch("needs"), "preview-gate" ],
+ [ "deployment record if", deployment_record_job.fetch("if"), "needs.preview-gate.outputs.should_deploy == 'true'" ],
+ [ "deployment record permissions", deployment_record_job.fetch("permissions"), EXPECTED_DEPLOYMENT_PERMISSIONS ],
+ [ "deployment record timeout", deployment_record_job.fetch("timeout-minutes"), 5 ],
+ [ "deployment record output", deployment_record_job.dig("outputs", "deployment_id"), "${{ steps.deployment.outputs.result }}" ],
+ [ "deployment record HEAD_SHA env", deployment_record_job.dig("env", "HEAD_SHA"), "${{ needs.preview-gate.outputs.head_sha }}" ],
+ [ "deployment record IS_FORK env", deployment_record_job.dig("env", "IS_FORK"), "${{ needs.preview-gate.outputs.is_fork }}" ],
+ [ "deployment record PR_NUMBER env", deployment_record_job.dig("env", "PR_NUMBER"), "${{ needs.preview-gate.outputs.pr_number }}" ],
+ [ "deploy job needs", deploy_job.fetch("needs"), [ "preview-gate", "deployment_record" ] ],
[ "deploy job permissions", deploy_job.fetch("permissions"), EXPECTED_DEPLOY_PERMISSIONS ],
[ "deploy job environment", environment_name(deploy_job), "preview" ],
[ "deploy job timeout", deploy_job.fetch("timeout-minutes"), 45 ],
+ [ "deploy preview output", deploy_job.dig("outputs", "preview_url"), "${{ steps.deploy.outputs.preview_url }}" ],
[ "deploy concurrency group", deploy_job.dig("concurrency", "group"), "preview-deploy-${{ needs.preview-gate.outputs.pr_number }}" ],
[ "deploy concurrency cancellation", deploy_job.dig("concurrency", "cancel-in-progress"), true ],
[ "deploy ARTIFACT_NAME env", deploy_job.dig("env", "ARTIFACT_NAME"), "${{ needs.preview-gate.outputs.artifact_name }}" ],
[ "deploy HEAD_SHA env", deploy_job.dig("env", "HEAD_SHA"), "${{ needs.preview-gate.outputs.head_sha }}" ],
+ [ "deploy IS_FORK env", deploy_job.dig("env", "IS_FORK"), "${{ needs.preview-gate.outputs.is_fork }}" ],
[ "deploy PR_NUMBER env", deploy_job.dig("env", "PR_NUMBER"), "${{ needs.preview-gate.outputs.pr_number }}" ],
+ [ "deployment status needs", deployment_status_job.fetch("needs"), [ "preview-gate", "deployment_record", "deploy-preview" ] ],
+ [ "deployment status permissions", deployment_status_job.fetch("permissions"), EXPECTED_DEPLOYMENT_PERMISSIONS ],
+ [ "deployment status timeout", deployment_status_job.fetch("timeout-minutes"), 5 ],
+ [ "deployment status DEPLOYMENT_ID env", deployment_status_job.dig("env", "DEPLOYMENT_ID"), "${{ needs.deployment_record.outputs.deployment_id }}" ],
+ [ "deployment status DEPLOY_RESULT env", deployment_status_job.dig("env", "DEPLOY_RESULT"), "${{ needs.deploy-preview.result }}" ],
+ [ "deployment status PREVIEW_URL env", deployment_status_job.dig("env", "PREVIEW_URL"), "${{ needs.deploy-preview.outputs.preview_url }}" ],
+ [ "preview comment needs", preview_comment_job.fetch("needs"), [ "preview-gate", "deploy-preview" ] ],
+ [ "preview comment if", preview_comment_job.fetch("if"), "needs.deploy-preview.result == 'success'" ],
+ [ "preview comment permissions", preview_comment_job.fetch("permissions"), EXPECTED_COMMENT_PERMISSIONS ],
+ [ "preview comment timeout", preview_comment_job.fetch("timeout-minutes"), 5 ],
+ [ "preview comment HEAD_SHA env", preview_comment_job.dig("env", "HEAD_SHA"), "${{ needs.preview-gate.outputs.head_sha }}" ],
+ [ "preview comment PR_NUMBER env", preview_comment_job.dig("env", "PR_NUMBER"), "${{ needs.preview-gate.outputs.pr_number }}" ],
+ [ "preview comment PREVIEW_URL env", preview_comment_job.dig("env", "PREVIEW_URL"), "${{ needs.deploy-preview.outputs.preview_url }}" ],
+ [ "gate trusted checkout ref", gate_trusted_checkout.dig("with", "ref"), "${{ github.event.repository.default_branch }}" ],
+ [ "gate trusted checkout path", gate_trusted_checkout.dig("with", "path"), "trusted-preview-resolver" ],
+ [ "gate trusted checkout credentials", gate_trusted_checkout.dig("with", "persist-credentials"), false ],
[ "PR checkout credentials", pr_checkout.dig("with", "persist-credentials"), false ],
[ "upload artifact name", upload_image.dig("with", "name"), "preview-image-pr-${{ env.PR_NUMBER }}-${{ env.HEAD_SHA }}" ],
[ "upload artifact retention", upload_image.dig("with", "retention-days"), 3 ],
@@ -207,6 +259,10 @@ deploy = step!(deploy_steps, "Deploy to Cloudflare Containers")
[ "download artifact run id", download_artifact.dig("with", "run-id"), "${{ github.event.workflow_run.id }}" ],
[ "download artifact token", download_artifact.dig("with", "github-token"), "${{ github.token }}" ],
[ "download artifact path", download_artifact.dig("with", "path"), "${{ runner.temp }}/preview-image" ],
+ [ "fork deployment record guard", create_deployment.fetch("if"), "env.IS_FORK == 'false'" ],
+ [ "diagnostics upload name", upload_diagnostics.dig("with", "name"), "preview-diagnostics-pr-${{ env.PR_NUMBER }}-${{ env.HEAD_SHA }}" ],
+ [ "diagnostics upload path", upload_diagnostics.dig("with", "path"), "${{ runner.temp }}/preview-diagnostics.json" ],
+ [ "diagnostics upload retention", upload_diagnostics.dig("with", "retention-days"), 3 ],
[ "Wrangler binary", wrangler.dig("bin", "wrangler"), "bin/wrangler.js" ]
].each { |label, actual, expected| assert(actual == expected, "#{label}: expected #{actual.inspect} to equal #{expected.inspect}") }
@@ -216,54 +272,80 @@ assert(!preview_on.key?("pull_request"), "privileged preview deploy workflow mus
assert(gate_job.fetch("if").include?("github.event.workflow_run.event == 'pull_request'"), "preview gate must only accept pull_request workflow runs")
assert(gate_job.fetch("if").include?("github.event.workflow_run.conclusion == 'success'"), "preview gate must only accept successful PR workflow runs")
assert(image_job.fetch("if").include?("preview-cf"), "preview image build must stay gated by preview-cf")
-assert(deploy_job.fetch("if") == "needs.preview-gate.outputs.should_deploy == 'true'", "privileged preview deploy must depend on the gate output")
+assert(deploy_job.fetch("if").include?("needs.preview-gate.outputs.should_deploy == 'true'"), "privileged preview deploy must depend on the gate output")
+assert(deploy_job.fetch("if").include?("needs.deployment_record.result == 'success'"), "privileged preview deploy must require deployment record success or skip")
+assert(deploy_job.fetch("if").include?("needs.deployment_record.result == 'skipped'"), "privileged preview deploy must allow skipped deployment records")
+assert(deployment_status_job.fetch("if").include?("needs.preview-gate.outputs.is_fork == 'false'"), "deployment status job must only run for same-repository PRs")
+assert(deployment_status_job.fetch("if").include?("needs.deployment_record.result == 'success'"), "deployment status job must require a created deployment")
assert(gate_job["environment"].nil?, "preview gate must not use a protected secret-bearing environment")
assert(image_job["environment"].nil?, "preview image build must not use a protected secret-bearing environment")
+assert(deployment_record_job["environment"].nil?, "deployment record job must not use a protected secret-bearing environment")
+assert(deployment_status_job["environment"].nil?, "deployment status job must not use a protected secret-bearing environment")
+assert(preview_comment_job["environment"].nil?, "preview comment job must not use a protected secret-bearing environment")
assert(lockfile.dig("packages", "", "devDependencies", "wrangler"), "Wrangler must stay a root dev dependency")
assert(lockfile.fetch("lockfileVersion") >= 3, "preview tooling lockfile must preserve npm ci integrity metadata")
assert(wrangler.fetch("resolved").start_with?("https://registry.npmjs.org/wrangler/-/wrangler-"), "Wrangler must resolve from npm registry")
assert(wrangler.fetch("integrity").start_with?("sha512-"), "Wrangler lockfile entry must keep npm integrity metadata")
+assert(gate_trusted_checkout.dig("with", "sparse-checkout").to_s.include?("workers/preview/deploy"), "trusted gate checkout must include preview resolver")
assert(trusted_checkout.dig("with", "sparse-checkout").to_s.include?("workers/preview"), "trusted checkout must include preview tooling")
assert(deploy_step_names.compact.uniq == deploy_step_names.compact, "workflow step names must stay unique for security checks")
-assert([ trusted_checkout, download_artifact, verify_checksum, prepare, load_image, push_image, configure_image, deploy ].map { |step| deploy_steps.index(step) }.each_cons(2).all? { |left, right| left < right }, "deploy workflow steps must preserve safe cross-run artifact deploy order")
+assert([ gate_trusted_checkout, resolve_preview ].map { |step| gate_steps.index(step) }.each_cons(2).all? { |left, right| left < right }, "gate workflow steps must checkout trusted resolver before use")
+assert([ trusted_checkout, download_artifact, verify_checksum, prepare, load_image, push_image, configure_image, deploy, warm_preview, collect_diagnostics, upload_diagnostics ].map { |step| deploy_steps.index(step) }.each_cons(2).all? { |left, right| left < right }, "deploy workflow steps must preserve safe cross-run artifact deploy order")
assert(deploy_steps.none? { |step| step["name"] == "Checkout PR code" }, "privileged deploy job must not checkout PR code")
assert(env_hash(deploy_job).keys.none? { |name| name.start_with?("CLOUDFLARE_") }, "Cloudflare secrets must not be job-wide")
assert(env_hash(gate_job).keys.none? { |name| name.start_with?("CLOUDFLARE_") }, "preview gate must not receive Cloudflare secrets")
assert(env_hash(image_job).keys.none? { |name| name.start_with?("CLOUDFLARE_") }, "preview image build must not receive Cloudflare secrets")
+assert(env_hash(deployment_record_job).keys.none? { |name| name.start_with?("CLOUDFLARE_") }, "deployment record job must not receive Cloudflare secrets")
+assert(env_hash(deployment_status_job).keys.none? { |name| name.start_with?("CLOUDFLARE_") }, "deployment status job must not receive Cloudflare secrets")
+assert(env_hash(preview_comment_job).keys.none? { |name| name.start_with?("CLOUDFLARE_") }, "preview comment job must not receive Cloudflare secrets")
assert(upload_image.dig("with", "path").to_s.include?("sure-preview-image.tar.gz"), "preview image artifact must include the image archive")
assert(upload_image.dig("with", "path").to_s.include?("sure-preview-image.sha256"), "preview image artifact must include the checksum")
+assert(upload_image.dig("with", "path").to_s.include?("sure-preview-image.manifest.json"), "preview image artifact must include the manifest")
assert_pinned_actions!(gate_steps)
assert_pinned_actions!(image_steps)
+assert_pinned_actions!(deployment_record_steps)
assert_pinned_actions!(deploy_steps)
+assert_pinned_actions!(deployment_status_steps)
+assert_pinned_actions!(preview_comment_steps)
assert_no_inline_expressions!(gate_steps)
assert_no_inline_expressions!(image_steps)
+assert_no_inline_expressions!(deployment_record_steps)
assert_no_inline_expressions!(deploy_steps)
+assert_no_inline_expressions!(deployment_status_steps)
+assert_no_inline_expressions!(preview_comment_steps)
-all_steps = gate_steps + image_steps + deploy_steps
+all_steps = gate_steps + image_steps + deployment_record_steps + deploy_steps + deployment_status_steps + preview_comment_steps
assert(all_steps.none? { |step| env_hash(step).values.join("\n").match?(INLINE_SECRET_EXPRESSION) && ![ push_image, deploy ].include?(step) }, "only Cloudflare steps may reference GitHub secrets")
assert(deploy_steps.none? { |step| normalized_working_directory(step["working-directory"]).match?(PR_CONTROLLED_WORKDIR) }, "privileged deploy steps must not run from PR-controlled dirs")
assert(deploy_steps.none? { |step| run(step).include?("npx wrangler") }, "privileged deploy workflow must not use npx wrangler")
assert(deploy_steps.none? { |step| run(step).match?(/Dockerfile\.preview|docker build|docker save/) }, "privileged deploy job must not build PR Dockerfiles")
assert(deploy_steps.none? { |step| run(step).include?("${GITHUB_WORKSPACE}/pr") || run(step).include?(" pr/") }, "privileged deploy job must not reference PR checkout paths")
+assert((deployment_record_steps + deployment_status_steps + preview_comment_steps).none? { |step| [ step["uses"], run(step) ].compact.join("\n").include?("download-artifact") }, "GitHub write jobs must not download PR artifacts")
+assert((deployment_record_steps + deployment_status_steps + preview_comment_steps).none? { |step| run(step).match?(/docker |wrangler|npm /) }, "GitHub write jobs must not execute preview artifact, deploy, or package tooling")
assert(image_steps.none? { |step| env_hash(step).keys.any? { |key| key.start_with?("CLOUDFLARE_") } }, "preview image workflow must not expose Cloudflare secret env")
assert(image_steps.none? { |step| [ run(step), env_hash(step).values.join("\n") ].join("\n").match?(INLINE_SECRET_EXPRESSION) }, "preview image workflow must not reference GitHub secrets")
assert_run_includes(
resolve_preview,
- "context.payload.workflow_run",
+ "require('./trusted-preview-resolver/workers/preview/deploy/resolve_preview_request.cjs')",
+ "resolvePreviewRequest({ github, context, core })"
+)
+
+[
"workflowRun.pull_requests?.[0]",
- "github.rest.pulls.get",
+ "github.rest.repos.listPullRequestsAssociatedWithCommit",
+ "parsePreviewArtifactName",
"pullRequest.head.sha !== headSha",
+ "is stale for PR",
"preview-cf",
- "github.rest.pulls.listFiles",
- "filename.startsWith('.github/workflows/')",
- "github.rest.actions.listWorkflowRunArtifacts",
+ "filename.startsWith(\".github/workflows/\")",
"preview-image-pr-${prNumber}-${headSha}",
"!item.expired",
- "core.setOutput('artifact_name', artifactName)",
- "core.setOutput('should_deploy', 'true')"
-)
+ "core.setOutput(\"artifact_name\", artifactName)",
+ "core.setOutput(\"is_fork\", String(isFork))",
+ "core.setOutput(\"should_deploy\", \"true\")"
+].each { |needle| assert(resolver_script.include?(needle), "preview resolver must include #{needle.inspect}") }
prepare_run = assert_run_includes(prepare, *REQUIRED_PREPARE_LINES)
assert(!prepare_run.include?("npm install"), "prepare step must not use npm install")
@@ -274,12 +356,18 @@ assert(deploy_steps.select { |step| run(step).match?(/npm (ci|install)/) }.map {
image_build_run = assert_run_includes(build_image, *REQUIRED_IMAGE_BUILD_LINES)
assert(image_build_run.include?("set -euo pipefail"), "preview image build must fail closed")
assert(!image_build_run.include?("CLOUDFLARE_"), "preview image build must not receive Cloudflare secrets")
+assert(!image_build_run.include?('cat > "$manifest_file" < {
+ if (endpoint.endpointName === "listWorkflowRunArtifacts") {
+ assert.equal(params.run_id, 123);
+ return artifacts;
+ }
+
+ if (endpoint.endpointName === "listFiles") {
+ return files;
+ }
+
+ throw new Error(`unexpected paginate endpoint ${endpoint.endpointName}`);
+ },
+ rest: {
+ actions: {
+ listWorkflowRunArtifacts: { endpointName: "listWorkflowRunArtifacts" },
+ },
+ pulls: {
+ get: async ({ pull_number }) => {
+ assert.equal(pull_number, pullRequest.number);
+ return { data: pullRequest };
+ },
+ listFiles: { endpointName: "listFiles" },
+ },
+ repos: {
+ listPullRequestsAssociatedWithCommit: async () => ({ data: associatedPullRequests }),
+ },
+ },
+ };
+}
+
+function fakeCore() {
+ const outputs = {};
+ const messages = [];
+ let failure = null;
+
+ return {
+ core: {
+ info: (message) => messages.push(message),
+ setFailed: (message) => {
+ failure = message;
+ },
+ setOutput: (name, value) => {
+ outputs[name] = value;
+ },
+ },
+ get failure() {
+ return failure;
+ },
+ messages,
+ outputs,
+ };
+}
+
+describe("parsePreviewArtifactName", () => {
+ it("parses preview image artifact names", () => {
+ const parsed = parsePreviewArtifactName("preview-image-pr-2017-4f1159e99c7785bc370f53510284c251fabdb75b");
+
+ assert.deepEqual(parsed, {
+ prNumber: 2017,
+ headSha: "4f1159e99c7785bc370f53510284c251fabdb75b",
+ });
+ });
+
+ it("rejects malformed names", () => {
+ assert.equal(parsePreviewArtifactName("preview-image-pr-0-4f1159e99c7785bc370f53510284c251fabdb75b"), null);
+ assert.equal(parsePreviewArtifactName("preview-image-pr-2017-notasha"), null);
+ assert.equal(parsePreviewArtifactName("other-artifact"), null);
+ });
+});
+
+describe("selectPullRequestNumber", () => {
+ const headSha = "4f1159e99c7785bc370f53510284c251fabdb75b";
+ const context = contextFor({ id: 123, head_sha: headSha });
+
+ it("falls back to commit association when workflow_run has no PR payload", () => {
+ const selected = selectPullRequestNumber({
+ runPullRequest: undefined,
+ artifacts: [previewArtifact(2017, headSha)],
+ associatedPullRequests: [openPullRequest(2017, headSha, "Rene0422/sure")],
+ context,
+ headSha,
+ });
+
+ assert.deepEqual(selected, {
+ prNumber: 2017,
+ source: "commit_association",
+ });
+ });
+
+ it("uses a matching artifact when the same head SHA is associated with more than one PR", () => {
+ const selected = selectPullRequestNumber({
+ runPullRequest: undefined,
+ artifacts: [previewArtifact(2060, headSha)],
+ associatedPullRequests: [
+ openPullRequest(2059, headSha),
+ openPullRequest(2060, headSha),
+ ],
+ context,
+ headSha,
+ });
+
+ assert.deepEqual(selected, {
+ prNumber: 2060,
+ source: "artifact_and_commit_association",
+ });
+ });
+
+ it("refuses ambiguous associated PRs without a single matching artifact", () => {
+ const selected = selectPullRequestNumber({
+ runPullRequest: undefined,
+ artifacts: [],
+ associatedPullRequests: [
+ openPullRequest(2059, headSha),
+ openPullRequest(2060, headSha),
+ ],
+ context,
+ headSha,
+ });
+
+ assert.match(selected.error, /multiple open pull requests/);
+ });
+});
+
+describe("resolvePreviewRequest", () => {
+ const headSha = "4f1159e99c7785bc370f53510284c251fabdb75b";
+ const workflowRun = {
+ id: 123,
+ head_sha: headSha,
+ pull_requests: [],
+ };
+
+ it("resolves fork PRs from commit association and marks deployment creation as skippable", async () => {
+ const pullRequest = openPullRequest(2017, headSha, "Rene0422/sure");
+ const state = fakeCore();
+ const github = fakeGithub({
+ artifacts: [previewArtifact(2017, headSha)],
+ associatedPullRequests: [pullRequest],
+ pullRequest,
+ });
+
+ await resolvePreviewRequest({ github, context: contextFor(workflowRun), core: state.core });
+
+ assert.equal(state.failure, null);
+ assert.equal(state.outputs.should_deploy, "true");
+ assert.equal(state.outputs.pr_number, "2017");
+ assert.equal(state.outputs.head_sha, headSha);
+ assert.equal(state.outputs.artifact_name, `preview-image-pr-2017-${headSha}`);
+ assert.equal(state.outputs.is_fork, "true");
+ });
+
+ it("resolves PRs from artifact names when workflow and commit association metadata are unavailable", async () => {
+ const pullRequest = openPullRequest(2017, headSha, "Rene0422/sure");
+ const state = fakeCore();
+ const github = fakeGithub({
+ artifacts: [previewArtifact(2017, headSha)],
+ associatedPullRequests: [],
+ pullRequest,
+ });
+
+ await resolvePreviewRequest({ github, context: contextFor(workflowRun), core: state.core });
+
+ assert.equal(state.failure, null);
+ assert.equal(state.outputs.should_deploy, "true");
+ assert.equal(state.outputs.pr_number, "2017");
+ assert.equal(state.outputs.head_sha, headSha);
+ assert.equal(state.outputs.artifact_name, `preview-image-pr-2017-${headSha}`);
+ assert.equal(state.outputs.is_fork, "true");
+ assert.match(state.messages.join("\n"), /Resolved PR 2017 from artifact_name; fork=true/);
+ });
+
+ it("treats stale workflow runs as successful no-ops", async () => {
+ const currentHeadSha = "c79a325513160e651680170f817d802395c38d86";
+ const pullRequest = openPullRequest(2060, currentHeadSha);
+ const state = fakeCore();
+ const github = fakeGithub({
+ artifacts: [previewArtifact(2060, headSha)],
+ associatedPullRequests: [openPullRequest(2060, headSha)],
+ pullRequest,
+ });
+
+ await resolvePreviewRequest({ github, context: contextFor(workflowRun), core: state.core });
+
+ assert.equal(state.failure, null);
+ assert.equal(state.outputs.should_deploy, "false");
+ assert.match(state.messages.join("\n"), /is stale for PR 2060/);
+ });
+
+ it("fails closed when a labeled PR changed workflow files", async () => {
+ const pullRequest = openPullRequest(2060, headSha);
+ const state = fakeCore();
+ const github = fakeGithub({
+ artifacts: [previewArtifact(2060, headSha)],
+ associatedPullRequests: [pullRequest],
+ pullRequest,
+ files: [{ filename: ".github/workflows/pr.yml" }],
+ });
+
+ await resolvePreviewRequest({ github, context: contextFor(workflowRun), core: state.core });
+
+ assert.match(state.failure, /base-trusted workflow definitions/);
+ assert.equal(state.outputs.should_deploy, "false");
+ });
+
+ it("fails closed when the expected artifact is missing", async () => {
+ const pullRequest = openPullRequest(2060, headSha);
+ const state = fakeCore();
+ const github = fakeGithub({
+ artifacts: [],
+ associatedPullRequests: [pullRequest],
+ pullRequest,
+ });
+
+ await resolvePreviewRequest({ github, context: contextFor(workflowRun), core: state.core });
+
+ assert.match(state.failure, /did not publish preview-image-pr-2060-/);
+ assert.equal(state.outputs.should_deploy, "false");
+ });
+
+ it("skips PRs without the preview label before requiring an artifact", async () => {
+ const pullRequest = openPullRequest(2060, headSha, "we-promise/sure", { labels: [] });
+ const state = fakeCore();
+ const github = fakeGithub({
+ artifacts: [],
+ associatedPullRequests: [pullRequest],
+ pullRequest,
+ });
+
+ await resolvePreviewRequest({ github, context: contextFor(workflowRun), core: state.core });
+
+ assert.equal(state.failure, null);
+ assert.equal(state.outputs.should_deploy, "false");
+ assert.match(state.messages.join("\n"), /does not have the preview-cf label/);
+ });
+});
diff --git a/workers/preview/deploy/resolve_preview_request.cjs b/workers/preview/deploy/resolve_preview_request.cjs
new file mode 100644
index 000000000..7aec276c1
--- /dev/null
+++ b/workers/preview/deploy/resolve_preview_request.cjs
@@ -0,0 +1,198 @@
+const PREVIEW_ARTIFACT_PATTERN = /^preview-image-pr-([1-9][0-9]*)-([a-f0-9]{40})$/;
+
+function parsePreviewArtifactName(name) {
+ const match = PREVIEW_ARTIFACT_PATTERN.exec(name);
+ if (!match) return null;
+
+ return {
+ prNumber: Number(match[1]),
+ headSha: match[2],
+ };
+}
+
+function repoFullName(context) {
+ return `${context.repo.owner}/${context.repo.repo}`;
+}
+
+function labelsIncludePreview(pullRequest) {
+ return pullRequest.labels.some((label) => label.name === "preview-cf");
+}
+
+function artifactCandidates(artifacts, headSha) {
+ return artifacts
+ .filter((artifact) => !artifact.expired)
+ .map((artifact) => ({
+ artifact,
+ parsed: parsePreviewArtifactName(artifact.name),
+ }))
+ .filter((candidate) => candidate.parsed?.headSha === headSha);
+}
+
+function uniqueNumbers(candidates) {
+ return [...new Set(candidates.map((candidate) => candidate.parsed.prNumber))];
+}
+
+function associatedPullRequestsForHead(associatedPullRequests, context, headSha) {
+ const baseRepo = repoFullName(context);
+
+ return associatedPullRequests.filter((pullRequest) => (
+ pullRequest.state === "open" &&
+ pullRequest.head?.sha === headSha &&
+ pullRequest.base?.repo?.full_name === baseRepo
+ ));
+}
+
+function selectPullRequestNumber({ runPullRequest, artifacts, associatedPullRequests, context, headSha }) {
+ if (runPullRequest?.number) {
+ return {
+ prNumber: runPullRequest.number,
+ source: "workflow_run",
+ };
+ }
+
+ const associatedHeadPullRequests = associatedPullRequestsForHead(associatedPullRequests, context, headSha);
+ const artifactPullRequestNumbers = uniqueNumbers(artifactCandidates(artifacts, headSha));
+
+ if (associatedHeadPullRequests.length === 1) {
+ return {
+ prNumber: associatedHeadPullRequests[0].number,
+ source: "commit_association",
+ };
+ }
+
+ if (associatedHeadPullRequests.length > 1) {
+ const associatedNumbers = new Set(associatedHeadPullRequests.map((pullRequest) => pullRequest.number));
+ const artifactMatches = artifactPullRequestNumbers.filter((number) => associatedNumbers.has(number));
+
+ if (artifactMatches.length === 1) {
+ return {
+ prNumber: artifactMatches[0],
+ source: "artifact_and_commit_association",
+ };
+ }
+
+ return {
+ error: `Workflow run head SHA ${headSha} is associated with multiple open pull requests and no single preview artifact matched`,
+ };
+ }
+
+ if (artifactPullRequestNumbers.length === 1) {
+ return {
+ prNumber: artifactPullRequestNumbers[0],
+ source: "artifact_name",
+ };
+ }
+
+ if (artifactPullRequestNumbers.length > 1) {
+ return {
+ error: `Workflow run ${headSha} published preview artifacts for multiple pull requests`,
+ };
+ }
+
+ return {
+ prNumber: null,
+ source: "none",
+ };
+}
+
+async function resolvePreviewRequest({ github, context, core }) {
+ const workflowRun = context.payload.workflow_run;
+ const runPullRequest = workflowRun.pull_requests?.[0];
+ const headSha = workflowRun.head_sha;
+
+ core.setOutput("should_deploy", "false");
+
+ const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ run_id: workflowRun.id,
+ per_page: 100,
+ });
+
+ const { data: associatedPullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ commit_sha: headSha,
+ });
+
+ const selected = selectPullRequestNumber({
+ runPullRequest,
+ artifacts,
+ associatedPullRequests,
+ context,
+ headSha,
+ });
+
+ if (selected.error) {
+ core.setFailed(selected.error);
+ return;
+ }
+
+ if (!selected.prNumber) {
+ core.info("Workflow run is not associated with an open pull request");
+ return;
+ }
+
+ const prNumber = selected.prNumber;
+ const { data: pullRequest } = await github.rest.pulls.get({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: prNumber,
+ });
+
+ if (pullRequest.state !== "open") {
+ core.info(`PR ${prNumber} is ${pullRequest.state}; skipping preview deploy`);
+ return;
+ }
+
+ if (pullRequest.head.sha !== headSha) {
+ core.info(`Workflow run head SHA ${headSha} is stale for PR ${prNumber}; current head is ${pullRequest.head.sha}`);
+ return;
+ }
+
+ const hasPreviewLabel = labelsIncludePreview(pullRequest);
+ if (!hasPreviewLabel) {
+ core.info(`PR ${prNumber} does not have the preview-cf label`);
+ return;
+ }
+
+ const files = await github.paginate(github.rest.pulls.listFiles, {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: prNumber,
+ per_page: 100,
+ });
+ const workflowChanges = files
+ .map((file) => file.filename)
+ .filter((filename) => filename.startsWith(".github/workflows/"));
+
+ if (workflowChanges.length > 0) {
+ core.setFailed(`Preview deployment requires base-trusted workflow definitions; changed workflow files: ${workflowChanges.join(", ")}`);
+ return;
+ }
+
+ const artifactName = `preview-image-pr-${prNumber}-${headSha}`;
+ const artifact = artifacts.find((item) => item.name === artifactName && !item.expired);
+
+ if (!artifact) {
+ core.setFailed(`Pull Request workflow run ${workflowRun.id} did not publish ${artifactName}`);
+ return;
+ }
+
+ const isFork = pullRequest.head.repo?.full_name !== repoFullName(context);
+ core.info(`Resolved PR ${prNumber} from ${selected.source}; fork=${isFork}`);
+
+ core.setOutput("artifact_name", artifactName);
+ core.setOutput("head_sha", headSha);
+ core.setOutput("is_fork", String(isFork));
+ core.setOutput("pr_number", String(prNumber));
+ core.setOutput("should_deploy", "true");
+}
+
+module.exports = {
+ artifactCandidates,
+ associatedPullRequestsForHead,
+ parsePreviewArtifactName,
+ resolvePreviewRequest,
+ selectPullRequestNumber,
+};