From 5f8452d63bad1b19ef9e2a743969346e69e6cea8 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Sun, 31 May 2026 04:30:03 -0700 Subject: [PATCH] ci(preview): stabilize Cloudflare preview deployments (#2062) * ci(preview): stabilize Cloudflare preview deployments * ci(preview): bound diagnostics and cover artifact fallback * ci(preview): isolate artifact deploy permissions * ci(preview): tidy deployment comment rendering * ci(preview): harden preview manifest generation * ci(preview): fail on preview diagnostics failure --- .github/workflows/pr.yml | 24 +- .github/workflows/preview-cleanup.yml | 2 +- .github/workflows/preview-deploy.yml | 350 ++++++++++++------ bin/preview_deploy_security_check.rb | 124 ++++++- .../resolve_preview_request_test.cjs | 285 ++++++++++++++ .../deploy/resolve_preview_request.cjs | 198 ++++++++++ 6 files changed, 845 insertions(+), 138 deletions(-) create mode 100644 test/javascript/preview_deploy/resolve_preview_request_test.cjs create mode 100644 workers/preview/deploy/resolve_preview_request.cjs 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, +};