diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76d7c52a5..779d0dbc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,9 +22,6 @@ jobs: - name: Scan for security vulnerabilities in Ruby dependencies run: bin/brakeman --no-pager - - name: Validate preview deploy workflow hardening - run: ruby bin/preview_deploy_security_check.rb - scan_js: runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/.github/workflows/pipelock.yml b/.github/workflows/pipelock.yml index d0e7c72cb..2a8feea35 100644 --- a/.github/workflows/pipelock.yml +++ b/.github/workflows/pipelock.yml @@ -30,7 +30,3 @@ jobs: config/locales/views/reports/ docs/hosting/ai.md app/models/provider/binance.rb - workers/preview/package-lock.json - # Preview Dockerfile uses local dev credentials (rails/rails) that are - # not real secrets; Dockerfile format does not support inline # pipelock:ignore - Dockerfile.preview diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 5c9e4fd50..a2a7f3da3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -3,7 +3,7 @@ name: Pull Request on: workflow_dispatch: pull_request: - types: [opened, synchronize, reopened, labeled] + types: [opened, synchronize, reopened] paths-ignore: - 'charts/**' @@ -13,72 +13,3 @@ permissions: jobs: ci: uses: ./.github/workflows/ci.yml - - preview_image: - needs: ci - if: | - contains(github.event.pull_request.labels.*.name, 'preview-cf') && - (github.event.action != 'labeled' || github.event.label.name == 'preview-cf') - name: Build Cloudflare preview image - runs-on: ubuntu-latest - timeout-minutes: 30 - permissions: - contents: read - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - IMAGE_TAG: sure-preview-pr-${{ github.event.pull_request.number }}:${{ github.event.pull_request.head.sha }} - steps: - - name: Checkout PR code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - with: - persist-credentials: false - - - name: Build preview image without secrets - run: | - 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 \ - --build-arg "BUILD_COMMIT_SHA=${HEAD_SHA}" \ - -f Dockerfile.preview \ - -t "${IMAGE_TAG}" \ - . - - docker image inspect "${IMAGE_TAG}" >/dev/null - docker save "${IMAGE_TAG}" | gzip -1 > "$image_archive" - 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 - with: - name: preview-image-pr-${{ env.PR_NUMBER }}-${{ env.HEAD_SHA }} - 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 deleted file mode 100644 index 052708868..000000000 --- a/.github/workflows/preview-cleanup.yml +++ /dev/null @@ -1,249 +0,0 @@ -name: Cleanup PR Previews - -on: - # Run hourly to check for expired previews - schedule: - - cron: '0 * * * *' - - # Immediately cleanup when PR is closed - pull_request: - types: [closed, unlabeled] - - # Allow manual trigger - workflow_dispatch: - inputs: - pr_number: - description: 'PR number to cleanup (optional, cleans all expired if empty)' - required: false - type: string - -permissions: - contents: read - deployments: write - -jobs: - cleanup-on-close: - name: Cleanup closed PR preview - if: | - github.event_name == 'pull_request' && - ( - (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview-cf')) || - (github.event.action == 'unlabeled' && github.event.label.name == 'preview-cf') - ) - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - with: - persist-credentials: false - - - name: Delete preview Worker - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN || secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - run: | - set -euo pipefail - - WORKER_NAME="sure-preview-${{ github.event.pull_request.number }}" - echo "Deleting Worker: $WORKER_NAME" - - if [ -z "${CLOUDFLARE_API_TOKEN:-}" ] || [ -z "${CLOUDFLARE_ACCOUNT_ID:-}" ]; then - echo "Missing Cloudflare credentials; refusing to mark preview cleanup as successful" - exit 1 - fi - - # Delete the worker (this also stops any running containers) - response_file="$(mktemp)" - http_status="$(curl -sS -o "$response_file" -w "%{http_code}" -X DELETE \ - "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts/$WORKER_NAME?force=true" \ - -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ - -H "Content-Type: application/json")" || { - echo "Failed to delete preview Worker via Cloudflare API" - exit 1 - } - response="$(cat "$response_file")" - - if [ -z "$response" ] && [[ "$http_status" =~ ^2 ]]; then - echo "Deleted Worker: $WORKER_NAME" - elif [ -n "$response" ] && echo "$response" | jq -e '.success == true' >/dev/null; then - echo "Deleted Worker: $WORKER_NAME" - elif [ -n "$response" ] && echo "$response" | jq -e '[.errors[]?.code] | index(10007)' >/dev/null; then - echo "$response" | jq -c '.errors' - echo "Worker may not exist" - else - echo "Cloudflare API failed to delete preview Worker (HTTP $http_status)" - if [ -n "$response" ]; then - echo "$response" | jq -c '.errors // .' || printf '%s\n' "$response" - else - echo "No response body" - fi - exit 1 - fi - - - name: Delete GitHub Deployment - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - const environment = `preview-pr-${{ github.event.pull_request.number }}`; - const description = context.payload.action === 'closed' - ? 'PR closed - preview deleted' - : 'preview-cf label removed - preview deleted'; - - try { - // Get deployments for this environment - const { data: deployments } = await github.rest.repos.listDeployments({ - owner: context.repo.owner, - repo: context.repo.repo, - environment: environment - }); - - // Mark all deployments as inactive - for (const deployment of deployments) { - await github.rest.repos.createDeploymentStatus({ - owner: context.repo.owner, - repo: context.repo.repo, - deployment_id: deployment.id, - state: 'inactive', - description - }); - } - - console.log(`Marked ${deployments.length} deployments as inactive`); - } catch (error) { - console.log('No deployments to cleanup or error:', error.message); - } - - cleanup-expired: - name: Cleanup expired previews - if: github.repository == 'we-promise/sure' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - with: - persist-credentials: false - - - name: Setup Node.js - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 - with: - node-version: "24" - - - name: Install Wrangler - run: npm install -g wrangler - - - name: Cleanup expired previews - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_INPUT: ${{ inputs.pr_number }} - run: | - # If specific PR number provided, only cleanup that one - if [ -n "$PR_INPUT" ]; then - if [[ "$PR_INPUT" =~ ^[1-9][0-9]*$ ]]; then - PR_NUM="$PR_INPUT" - WORKER_NAME="sure-preview-$PR_NUM" - echo "Manually deleting Worker: $WORKER_NAME" - wrangler delete --name "$WORKER_NAME" --force || echo "Worker may not exist" - - # Cleanup GitHub deployment for this PR - echo "Cleaning up GitHub deployment for PR #$PR_NUM" - gh api \ - -X GET "/repos/${{ github.repository }}/deployments?environment=preview-pr-$PR_NUM" \ - --jq '.[].id' 2>/dev/null | while read -r DEPLOY_ID; do - if [ -n "$DEPLOY_ID" ]; then - gh api \ - -X POST "/repos/${{ github.repository }}/deployments/$DEPLOY_ID/statuses" \ - -f state=inactive \ - -f description="Preview manually deleted" || true - fi - done || echo "No deployments to cleanup or error occurred" - else - echo "Invalid PR number input '$PR_INPUT'; skipping manual cleanup" - fi - - exit 0 - fi - - # Get list of all preview workers - echo "Fetching list of preview workers..." - - # Use Cloudflare API to list workers and read modified_on from the list response. - # The per-script endpoint returns raw script content, not JSON metadata. - WORKERS_RESPONSE=$(curl -fsS -X GET \ - "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts" \ - -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ - -H "Content-Type: application/json") || { - echo "Failed to fetch preview worker list from Cloudflare" - exit 1 - } - - if ! echo "$WORKERS_RESPONSE" | jq -e '.success == true and (.result | type == "array")' >/dev/null 2>&1; then - echo "Cloudflare API returned an invalid worker list response" - echo "$WORKERS_RESPONSE" | jq -c '.errors // .' - exit 1 - fi - - WORKERS=$(echo "$WORKERS_RESPONSE" | jq -r ' - .result[] - | select(.id | startswith("sure-preview-")) - | [.id, (.modified_on // "")] - | @tsv - ') - - if [ -z "$WORKERS" ]; then - echo "No preview workers found" - exit 0 - fi - - echo "Found preview workers:" - echo "$WORKERS" | cut -f1 - - # Check each worker's deployment time - CUTOFF_TIME=$(date -d '24 hours ago' +%s) - - while IFS=$'\t' read -r WORKER MODIFIED_ON; do - [ -n "$WORKER" ] || continue - echo "Checking $WORKER..." - - if [ -z "$MODIFIED_ON" ]; then - echo "No modified_on timestamp for $WORKER; skipping" - continue - fi - - if ! MODIFIED_TS=$(date -d "$MODIFIED_ON" +%s 2>/dev/null); then - echo "Invalid modified_on timestamp for $WORKER ($MODIFIED_ON); skipping" - continue - fi - - if [ "$MODIFIED_TS" -lt "$CUTOFF_TIME" ]; then - 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="${WORKER#sure-preview-}" - if [[ "$PR_NUM" =~ ^[1-9][0-9]*$ ]]; then - echo "Cleaning up GitHub deployment for PR #$PR_NUM" - gh api \ - -X GET "/repos/${{ github.repository }}/deployments?environment=preview-pr-$PR_NUM" \ - --jq '.[].id' 2>/dev/null | while read -r DEPLOY_ID; do - gh api \ - -X POST "/repos/${{ github.repository }}/deployments/$DEPLOY_ID/statuses" \ - -f state=inactive \ - -f description="Preview expired after 24 hours" || true - done || echo "No deployments to cleanup or error occurred" - else - echo "Could not extract a valid PR number from $WORKER; skipping deployment cleanup" - fi - else - echo "Failed to delete $WORKER; skipping deployment status update" - fi - else - echo "Worker $WORKER is still within 24-hour window, keeping..." - fi - done <<< "$WORKERS" - - echo "Cleanup complete" diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml deleted file mode 100644 index 3064ab9f6..000000000 --- a/.github/workflows/preview-deploy.yml +++ /dev/null @@ -1,636 +0,0 @@ -name: Deploy PR Preview - -on: - workflow_run: - workflows: ["Pull Request"] - types: [completed] - -permissions: - contents: read - -jobs: - preview-gate: - if: | - github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.conclusion == 'success' - name: Validate preview deployment gates - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - actions: read - contents: read - pull-requests: read - outputs: - artifact_name: ${{ steps.preview.outputs.artifact_name }} - head_sha: ${{ steps.preview.outputs.head_sha }} - is_fork: ${{ steps.preview.outputs.is_fork }} - pr_number: ${{ steps.preview.outputs.pr_number }} - resolution_source: ${{ steps.preview.outputs.resolution_source }} - should_deploy: ${{ steps.preview.outputs.should_deploy }} - - steps: - - name: Checkout trusted preview resolver - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - with: - ref: ${{ github.event.repository.default_branch }} - path: trusted-preview-resolver - persist-credentials: false - sparse-checkout: | - workers/preview/deploy - - - name: Resolve preview request - id: preview - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - const { resolvePreviewRequest } = require('./trusted-preview-resolver/workers/preview/deploy/resolve_preview_request.cjs'); - await resolvePreviewRequest({ github, context, core }); - - deployment_record: - needs: preview-gate - if: needs.preview-gate.outputs.should_deploy == 'true' - name: Create GitHub Deployment - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - deployments: write - outputs: - deployment_id: ${{ steps.deployment.outputs.result }} - env: - HEAD_SHA: ${{ needs.preview-gate.outputs.head_sha }} - IS_FORK: ${{ needs.preview-gate.outputs.is_fork }} - PR_NUMBER: ${{ needs.preview-gate.outputs.pr_number }} - - steps: - - name: Create GitHub Deployment - if: env.IS_FORK == 'false' - id: deployment - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - const prNumber = process.env.PR_NUMBER; - const headSha = process.env.HEAD_SHA; - const deployment = await github.rest.repos.createDeployment({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: headSha, - environment: `preview-pr-${prNumber}`, - auto_merge: false, - required_contexts: [], - description: 'PR Preview Deployment' - }); - return deployment.data.id; - result-encoding: string - - deploy-preview: - needs: [preview-gate, deployment_record] - if: | - always() && - needs.preview-gate.outputs.should_deploy == 'true' && - (needs.deployment_record.result == 'success' || needs.deployment_record.result == 'skipped') - name: Deploy to Cloudflare Containers - runs-on: ubuntu-latest - timeout-minutes: 45 - concurrency: - group: preview-deploy-${{ needs.preview-gate.outputs.pr_number }} - cancel-in-progress: true - environment: preview - permissions: - actions: read - contents: read - outputs: - preview_url: ${{ steps.deploy.outputs.preview_url }} - env: - ARTIFACT_NAME: ${{ needs.preview-gate.outputs.artifact_name }} - HEAD_SHA: ${{ needs.preview-gate.outputs.head_sha }} - IS_FORK: ${{ needs.preview-gate.outputs.is_fork }} - PR_NUMBER: ${{ needs.preview-gate.outputs.pr_number }} - RESOLUTION_SOURCE: ${{ needs.preview-gate.outputs.resolution_source }} - - steps: - - name: Checkout trusted preview tooling - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - with: - ref: ${{ github.event.repository.default_branch }} - path: trusted - persist-credentials: false - sparse-checkout: | - workers/preview - - - name: Download preview image artifact - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 - with: - name: ${{ env.ARTIFACT_NAME }} - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ github.token }} - path: ${{ runner.temp }}/preview-image - - - name: Setup Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - with: - node-version: "24" - - - name: Verify preview image artifact checksum - run: | - set -euo pipefail - - image_archive="$RUNNER_TEMP/preview-image/sure-preview-image.tar.gz" - checksum_file="$RUNNER_TEMP/preview-image/sure-preview-image.sha256" - manifest_file="$RUNNER_TEMP/preview-image/sure-preview-image.manifest.json" - expected_files="$(mktemp)" - actual_files="$(mktemp)" - - test -f "$image_archive" - test -f "$checksum_file" - test -f "$manifest_file" - - printf '%s\n' \ - sure-preview-image.manifest.json \ - sure-preview-image.sha256 \ - sure-preview-image.tar.gz | sort > "$expected_files" - find "$RUNNER_TEMP/preview-image" -maxdepth 1 -type f -printf '%f\n' | sort > "$actual_files" - - if ! diff -u "$expected_files" "$actual_files"; then - echo "Preview image artifact contained unexpected files" >&2 - exit 1 - fi - - expected_checksum="$(tr -d '[:space:]' < "$checksum_file")" - actual_checksum="$(sha256sum "$image_archive" | awk '{print $1}')" - - if [ "$expected_checksum" != "$actual_checksum" ]; then - echo "Preview image artifact checksum mismatch" >&2 - exit 1 - fi - - node - "$manifest_file" "$expected_checksum" <<'NODE' - const fs = require('node:fs'); - - const manifestPath = process.argv[2]; - const expectedChecksum = process.argv[3]; - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - const expectedImageTag = `sure-preview-pr-${process.env.PR_NUMBER}:${process.env.HEAD_SHA}`; - const expected = { - artifactVersion: 1, - archivePath: 'sure-preview-image.tar.gz', - archiveSha256: expectedChecksum, - headSha: process.env.HEAD_SHA, - imageTag: expectedImageTag, - prNumber: process.env.PR_NUMBER, - }; - - for (const [key, value] of Object.entries(expected)) { - if (manifest[key] !== value) { - throw new Error(`Preview image manifest ${key} mismatch`); - } - } - - if (!/^sha256:[a-f0-9]{64}$/.test(manifest.imageId || '')) { - throw new Error('Preview image manifest imageId is invalid'); - } - NODE - - - name: Prepare trusted preview deploy workspace - run: | - set -euo pipefail - - preview_dir="$RUNNER_TEMP/sure-preview-worker" - rm -rf "$preview_dir" - mkdir -p "$preview_dir" - - cp trusted/workers/preview/package.json "$preview_dir/package.json" - cp trusted/workers/preview/package-lock.json "$preview_dir/package-lock.json" - cp trusted/workers/preview/tsconfig.json "$preview_dir/tsconfig.json" - cp trusted/workers/preview/wrangler.toml "$preview_dir/wrangler.toml" - cp -R trusted/workers/preview/src "$preview_dir/src" - mkdir -p "$preview_dir/deploy" - cp trusted/workers/preview/deploy/redact_preview_log.sh "$preview_dir/deploy/redact_preview_log.sh" - cp trusted/workers/preview/deploy/render_preview_config.cjs "$preview_dir/deploy/render_preview_config.cjs" - chmod 0755 "$preview_dir/deploy/redact_preview_log.sh" - - diagnostics_nonce="$(openssl rand -hex 32)" - sed -i "s/\${PR_NUMBER}/${PR_NUMBER}/g" "$preview_dir/wrangler.toml" - sed -i "s/\${PR_NUMBER}/${PR_NUMBER}/g" "$preview_dir/src/index.ts" - sed -i "s/\${PREVIEW_DIAGNOSTICS_NONCE}/${diagnostics_nonce}/g" "$preview_dir/src/index.ts" - cp "$preview_dir/wrangler.toml" "$preview_dir/wrangler.source.toml" - - if grep -F "\${PREVIEW_DIAGNOSTICS_NONCE}" "$preview_dir/src/index.ts" >/dev/null; then - echo "Preview diagnostics nonce placeholder was not replaced" >&2 - exit 1 - fi - - cd "$preview_dir" - npm ci --ignore-scripts --no-audit --no-fund - - - name: Load preview image artifact - run: | - set -euo pipefail - - image_archive="$RUNNER_TEMP/preview-image/sure-preview-image.tar.gz" - manifest_file="$RUNNER_TEMP/preview-image/sure-preview-image.manifest.json" - expected_image="sure-preview-pr-${PR_NUMBER}:${HEAD_SHA}" - - gzip -dc "$image_archive" | docker load - docker image inspect "$expected_image" >/dev/null - expected_image_id="$(node -e 'const fs = require("node:fs"); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1], "utf8")).imageId);' "$manifest_file")" - actual_image_id="$(docker image inspect --format '{{.Id}}' "$expected_image")" - - if [ "$expected_image_id" != "$actual_image_id" ]; then - echo "Loaded preview image ID did not match artifact manifest" >&2 - exit 1 - fi - - - name: Push preview image to Cloudflare registry - id: image - env: - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN || secrets.CLOUDFLARE_API_TOKEN }} - run: | - set -euo pipefail - - cd "$RUNNER_TEMP/sure-preview-worker" - source_config="$RUNNER_TEMP/sure-preview-worker/wrangler.source.toml" - config_path="$RUNNER_TEMP/sure-preview-worker/wrangler.toml" - image_tag="sure-preview-pr-${PR_NUMBER}:${HEAD_SHA}" - temporary_image_ref="registry.cloudflare.com/${CLOUDFLARE_ACCOUNT_ID}/${image_tag}" - push_log="$RUNNER_TEMP/wrangler-containers-push.log" - clean_log="$RUNNER_TEMP/wrangler-containers-push.clean.log" - push_status=0 - - # wrangler containers push validates wrangler.toml, so point the trusted - # config at a registry-shaped ref while it pushes the verified local image. - PREVIEW_IMAGE_REF="$temporary_image_ref" node ./deploy/render_preview_config.cjs render "$source_config" "$config_path" - cp "$config_path" "$RUNNER_TEMP/wrangler-push.toml" - - set +e - ./node_modules/.bin/wrangler containers push "$image_tag" 2>&1 | tee "$push_log" | ./deploy/redact_preview_log.sh - push_status=${PIPESTATUS[0]} - set -e - perl -pe 's/\e\[[0-9;]*[A-Za-z]//g' "$push_log" > "$clean_log" - - if [ "$push_status" -ne 0 ]; then - exit "$push_status" - fi - - image_ref="$(node ./deploy/render_preview_config.cjs find "$clean_log")" - - if [ -z "$image_ref" ]; then - echo "Could not find Cloudflare registry image reference in wrangler output" >&2 - exit 1 - fi - - echo "image_ref=${image_ref}" >> "$GITHUB_OUTPUT" - - - name: Configure trusted preview image reference - env: - IMAGE_REF: ${{ steps.image.outputs.image_ref }} - run: | - set -euo pipefail - - source_config="$RUNNER_TEMP/sure-preview-worker/wrangler.source.toml" - config_path="$RUNNER_TEMP/sure-preview-worker/wrangler.toml" - # Render from the preserved trusted source template so the push-time - # registry ref cannot make the final deploy rewrite stateful. - PREVIEW_IMAGE_REF="$IMAGE_REF" node "$RUNNER_TEMP/sure-preview-worker/deploy/render_preview_config.cjs" render "$source_config" "$config_path" - cp "$config_path" "$RUNNER_TEMP/wrangler-final.toml" - - # Print a redacted copy for logs without mutating the config used by deploy. - redacted_config="$RUNNER_TEMP/wrangler-redacted.toml" - "$RUNNER_TEMP/sure-preview-worker/deploy/redact_preview_log.sh" < "$config_path" > "$redacted_config" - cat "$redacted_config" - - - name: Deploy to Cloudflare Containers - id: deploy - env: - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN || secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_WORKERS_SUBDOMAIN: ${{ secrets.CLOUDFLARE_WORKERS_SUBDOMAIN }} - run: | - set -euo pipefail - - cd "$RUNNER_TEMP/sure-preview-worker" - deploy_log="$RUNNER_TEMP/wrangler-deploy.log" - clean_deploy_log="$RUNNER_TEMP/wrangler-deploy.clean.log" - - deploy_once() { - set +e - ./node_modules/.bin/wrangler deploy --config wrangler.toml --var "PR_NUMBER:${PR_NUMBER}" 2>&1 | tee "$deploy_log" | ./deploy/redact_preview_log.sh - local deploy_status=${PIPESTATUS[0]} - set -e - return "$deploy_status" - } - - if ! deploy_once; then - perl -pe 's/\e\[[0-9;]*[A-Za-z]//g' "$deploy_log" > "$clean_deploy_log" - - if grep -F "associated with a different durable object namespace" "$clean_deploy_log" >/dev/null; then - echo "Detected stale Cloudflare container app state for PR ${PR_NUMBER}; deleting preview Worker and retrying once." - if ! ./node_modules/.bin/wrangler delete --name "sure-preview-${PR_NUMBER}" --force; then - echo "Preview Worker delete failed; continuing to the single retry so wrangler deploy reports the final error if the stale state remains." >&2 - fi - deploy_once - else - exit 1 - fi - fi - - # Get the deployment URL - PREVIEW_URL="https://sure-preview-${PR_NUMBER}.${CLOUDFLARE_WORKERS_SUBDOMAIN}.workers.dev" - echo "preview_url=${PREVIEW_URL}" >> "$GITHUB_OUTPUT" - - - name: Warm preview container - env: - PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }} - run: | - echo "Triggering preview wake-up..." - curl -fsS --connect-timeout 5 --max-time 15 "$PREVIEW_URL/_container_status" >/dev/null || true - - - name: Collect preview diagnostics - if: success() - env: - PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }} - run: | - set -euo pipefail - - diagnostics_dir="$RUNNER_TEMP/preview-diagnostics" - diagnostics_file="$diagnostics_dir/preview-diagnostics.json" - latest_metrics_file="$diagnostics_dir/latest-metrics.json" - polls_log="$diagnostics_dir/metrics-polls.log" - summary_file="$diagnostics_dir/summary.md" - last_error="" - mkdir -p "$diagnostics_dir" - - # ~100 polls x ~3.5s = up to ~6 min. The preview must cold-start the - # container (≈1.3 GB image pull + sandbox init) and generate the full - # demo dataset before previewReady flips. A real Cloudflare standard-1 - # run measured previewReady at ~195s (rails ~46s, demo data ~149s) — - # the old 40-poll (~140s) budget would have failed a working preview. - # Keep generous headroom; the loop still breaks early on ready/failed. - for attempt in $(seq 1 100); do - if curl -fsS --connect-timeout 5 --max-time 15 "$PREVIEW_URL/_container_status" -o "$diagnostics_file"; then - if jq -e . "$diagnostics_file" >/dev/null 2>&1; then - jq -c --argjson attempt "$attempt" --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - '{attempt: $attempt, at: $at, previewReady: (.previewReady // false), previewFailed: (.previewFailed // false), progress: (.progress // {}), timings: (.timings // {})}' \ - "$diagnostics_file" >> "$polls_log" - jq '{previewReady: (.previewReady // false), previewFailed: (.previewFailed // false), progress: (.progress // {}), timings: (.timings // {})}' "$diagnostics_file" > "$latest_metrics_file" - - if jq -e '.previewReady == true or .previewFailed == true' "$diagnostics_file" >/dev/null; then - break - fi - else - last_error="invalid diagnostics JSON on attempt ${attempt}" - raw_snippet="$(head -c 2048 "$diagnostics_file")" - latest_metrics_snapshot="none" - if [ -f "$latest_metrics_file" ]; then - latest_metrics_snapshot="$(head -c 2048 "$latest_metrics_file")" - fi - jq -nc --argjson attempt "$attempt" --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg error "$last_error" --arg latestMetrics "$latest_metrics_snapshot" --arg rawSnippet "$raw_snippet" \ - '{attempt: $attempt, at: $at, error: $error, latestMetrics: $latestMetrics, rawSnippet: $rawSnippet}' >> "$polls_log" - fi - else - last_error="curl failed on attempt ${attempt}" - jq -nc --argjson attempt "$attempt" --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg error "$last_error" \ - '{attempt: $attempt, at: $at, error: $error}' >> "$polls_log" - fi - - sleep 3 - done - - if [ ! -s "$diagnostics_file" ] || ! jq -e . "$diagnostics_file" >/dev/null 2>&1; then - jq -n --arg error "${last_error:-preview diagnostics unavailable}" \ - --arg url "$PREVIEW_URL" \ - '{previewReady: false, previewFailed: false, error: $error, previewUrl: $url}' > "$diagnostics_file" - fi - - jq '{previewReady: (.previewReady // false), previewFailed: (.previewFailed // false), progress: (.progress // {}), timings: (.timings // {}), error: (.error // null)}' "$diagnostics_file" > "$latest_metrics_file" - { - echo "# Preview diagnostics" - echo - echo "- PR: ${PR_NUMBER}" - echo "- Commit: ${HEAD_SHA}" - echo "- Preview URL: ${PREVIEW_URL}" - echo "- Preview ready: $(jq -r '.previewReady // false' "$diagnostics_file")" - echo "- Preview failed: $(jq -r '.previewFailed // false' "$diagnostics_file")" - echo "- Phase: $(jq -r '.progress.phase // "unknown"' "$diagnostics_file")" - echo "- Stage: $(jq -r '.progress.stage // "unknown"' "$diagnostics_file")" - echo "- Seconds to Rails ready: $(jq -r '.timings.secondsToRailsReady // "unknown"' "$diagnostics_file")" - echo "- Seconds to demo data ready: $(jq -r '.timings.secondsToDemoDataReady // "unknown"' "$diagnostics_file")" - echo "- Seconds to preview ready: $(jq -r '.timings.secondsToPreviewReady // "unknown"' "$diagnostics_file")" - } > "$summary_file" - - jq -c . "$diagnostics_file" - - if jq -e '.previewFailed == true' "$diagnostics_file" >/dev/null; then - echo "Preview diagnostics from _container_status reported previewFailed=true:" >&2 - jq -c . "$diagnostics_file" >&2 - exit 1 - fi - - if ! jq -e '.previewReady == true' "$diagnostics_file" >/dev/null; then - echo "Preview diagnostics from _container_status did not reach previewReady=true:" >&2 - jq -c . "$diagnostics_file" >&2 - exit 1 - fi - - if ! jq -e '.timings.previewReadyAt != null and .timings.secondsToPreviewReady != null' "$diagnostics_file" >/dev/null; then - echo "Preview diagnostics are missing readiness timing fields:" >&2 - jq -c . "$diagnostics_file" >&2 - exit 1 - fi - - - name: Upload preview diagnostics - if: always() && steps.deploy.outputs.preview_url != '' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: preview-diagnostics-pr-${{ env.PR_NUMBER }}-${{ env.HEAD_SHA }} - path: ${{ runner.temp }}/preview-diagnostics - if-no-files-found: error - retention-days: 3 - - - name: Collect preview failure diagnostics - if: failure() - run: | - set -euo pipefail - - diagnostics_dir="$RUNNER_TEMP/preview-failure-diagnostics" - manifest_file="$RUNNER_TEMP/preview-image/sure-preview-image.manifest.json" - redaction_helper="$RUNNER_TEMP/sure-preview-worker/deploy/redact_preview_log.sh" - mkdir -p "$diagnostics_dir" - - jq -n \ - --arg artifactName "$ARTIFACT_NAME" \ - --arg headSha "$HEAD_SHA" \ - --arg isFork "$IS_FORK" \ - --arg prNumber "$PR_NUMBER" \ - --arg resolutionSource "$RESOLUTION_SOURCE" \ - '{ - artifactName: $artifactName, - headSha: $headSha, - isFork: $isFork, - prNumber: $prNumber, - resolutionSource: $resolutionSource - }' > "$diagnostics_dir/preview-request.json" - - sanitize_copy() { - local source="$1" - local destination="$2" - if [ -f "$source" ]; then - if [ -x "$redaction_helper" ]; then - "$redaction_helper" < "$source" > "$destination" - else - cp "$source" "$destination" - fi - fi - } - - if [ -f "$manifest_file" ]; then - jq '{ - artifactVersion, - archivePath, - archiveSha256, - headSha, - imageId, - imageTag, - prNumber - }' "$manifest_file" > "$diagnostics_dir/preview-image-manifest.json" - fi - - sanitize_copy "$RUNNER_TEMP/sure-preview-worker/wrangler.source.toml" "$diagnostics_dir/wrangler-source.toml" - sanitize_copy "$RUNNER_TEMP/wrangler-push.toml" "$diagnostics_dir/wrangler-push.toml" - sanitize_copy "$RUNNER_TEMP/wrangler-final.toml" "$diagnostics_dir/wrangler-final.toml" - sanitize_copy "$RUNNER_TEMP/sure-preview-worker/wrangler.toml" "$diagnostics_dir/wrangler.toml" - sanitize_copy "$RUNNER_TEMP/wrangler-containers-push.clean.log" "$diagnostics_dir/wrangler-containers-push.log" - if [ -f "$RUNNER_TEMP/wrangler-deploy.clean.log" ]; then - sanitize_copy "$RUNNER_TEMP/wrangler-deploy.clean.log" "$diagnostics_dir/wrangler-deploy.log" - else - sanitize_copy "$RUNNER_TEMP/wrangler-deploy.log" "$diagnostics_dir/wrangler-deploy.log" - fi - - find "$diagnostics_dir" -maxdepth 1 -type f -print - - - name: Upload preview failure diagnostics - if: failure() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: preview-failure-diagnostics-pr-${{ env.PR_NUMBER }}-${{ env.HEAD_SHA }} - path: ${{ runner.temp }}/preview-failure-diagnostics - if-no-files-found: error - retention-days: 3 - - - name: Prepare cleanup metadata - if: success() - run: | - set -euo pipefail - - metadata_dir="$RUNNER_TEMP/preview-cleanup-metadata" - mkdir -p "$metadata_dir" - "$RUNNER_TEMP/sure-preview-worker/deploy/redact_preview_log.sh" \ - < "$RUNNER_TEMP/sure-preview-worker/wrangler.toml" \ - > "$metadata_dir/wrangler.toml" - - - name: Store cleanup metadata - if: success() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: preview-cleanup-pr-${{ env.PR_NUMBER }} - path: ${{ runner.temp }}/preview-cleanup-metadata/wrangler.toml - retention-days: 2 - - deployment_status: - needs: [preview-gate, deployment_record, deploy-preview] - if: | - always() && - needs.preview-gate.outputs.should_deploy == 'true' && - needs.preview-gate.outputs.is_fork == 'false' && - needs.deployment_record.result == 'success' - name: Update GitHub Deployment Status - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - deployments: write - env: - DEPLOYMENT_ID: ${{ needs.deployment_record.outputs.deployment_id }} - DEPLOY_RESULT: ${{ needs.deploy-preview.result }} - PREVIEW_URL: ${{ needs.deploy-preview.outputs.preview_url }} - - steps: - - name: Update Deployment Status - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - const state = process.env.DEPLOY_RESULT === 'success' ? 'success' : 'failure'; - const previewUrl = process.env.PREVIEW_URL || undefined; - await github.rest.repos.createDeploymentStatus({ - owner: context.repo.owner, - repo: context.repo.repo, - deployment_id: Number(process.env.DEPLOYMENT_ID), - state: state, - environment_url: state === 'success' ? previewUrl : undefined, - description: state === 'success' ? 'Preview deployed successfully' : 'Preview deployment failed' - }); - - preview_comment: - needs: [preview-gate, deploy-preview] - if: needs.deploy-preview.result == 'success' - name: Comment on PR - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - pull-requests: write - env: - HEAD_SHA: ${{ needs.preview-gate.outputs.head_sha }} - PR_NUMBER: ${{ needs.preview-gate.outputs.pr_number }} - PREVIEW_URL: ${{ needs.deploy-preview.outputs.preview_url }} - - steps: - - name: Comment on PR - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - const previewUrl = process.env.PREVIEW_URL; - const issueNumber = Number(process.env.PR_NUMBER); - const headSha = process.env.HEAD_SHA; - const commentBody = [ - '## 🚀 Preview Deployment Ready', - '', - "Your preview environment has been deployed to Cloudflare Containers with the PR's Docker image.", - '', - `**Preview URL:** ${previewUrl}`, - '', - '> ⏰ This preview is intended to be cleaned up after **24 hours** of the last deployment once the cleanup workflow is live on the default branch.', - '> 💤 The container will sleep after 30 minutes of inactivity and wake on the next request.', - '', - '---', - `Deployed from commit ${headSha}`, - ].join('\n'); - - // Find existing comment - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber - }); - - const botComment = comments.find(comment => - comment.user.type === 'Bot' && - comment.body.includes('Preview Deployment Ready') - ); - - if (botComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body: commentBody - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body: commentBody - }); - } diff --git a/Dockerfile.preview b/Dockerfile.preview deleted file mode 100644 index 06a958a5e..000000000 --- a/Dockerfile.preview +++ /dev/null @@ -1,385 +0,0 @@ -# syntax = docker/dockerfile:1 - -# Preview Dockerfile for Cloudflare Containers -# Includes PostgreSQL and Redis for self-contained development testing - -ARG RUBY_VERSION=3.4.9 -FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base - -WORKDIR /rails - -# Install base packages including PostgreSQL and Redis servers -RUN apt-get update -qq \ - && apt-get install --no-install-recommends -y \ - curl libvips postgresql postgresql-client redis-server libyaml-0-2 procps sudo openssl strace \ - && rm -rf /var/lib/apt/lists /var/cache/apt/archives - -# Set development environment -ARG BUILD_COMMIT_SHA -ENV RAILS_ENV="development" \ - BUNDLE_PATH="/usr/local/bundle" \ - BUILD_COMMIT_SHA=${BUILD_COMMIT_SHA} - -# Build stage -FROM base AS build - -RUN apt-get update -qq \ - && apt-get install --no-install-recommends -y build-essential libpq-dev git pkg-config libyaml-dev \ - && rm -rf /var/lib/apt/lists /var/cache/apt/archives - -COPY .ruby-version Gemfile Gemfile.lock ./ -RUN bundle install \ - && rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git \ - && bundle exec bootsnap precompile --gemfile -j 0 - -COPY . . - -RUN bundle exec bootsnap precompile -j 0 app/ lib/ - -# Precompile assets -RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile - -# Final stage -FROM base - -# Create rails user and configure PostgreSQL/Redis permissions -RUN groupadd --system --gid 1000 rails && \ - useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ - echo "rails ALL=(ALL) NOPASSWD: /usr/bin/pg_ctlcluster, /usr/bin/redis-server" > /etc/sudoers.d/rails && \ - chmod 0440 /etc/sudoers.d/rails - -# Configure PostgreSQL to allow local connections. Target the highest-version -# cluster's pg_hba.conf -- the same one the entrypoint starts (ls -# /etc/postgresql | sort -V | tail -1) -- so the trust rules always land on the -# cluster that actually runs, even if multiple majors are present. -RUN PG_VERSION="$(ls /etc/postgresql 2>/dev/null | sort -V | tail -1)" && \ - PG_HBA="/etc/postgresql/${PG_VERSION}/main/pg_hba.conf" && \ - if [ -z "$PG_VERSION" ] || [ ! -f "$PG_HBA" ]; then \ - echo "ERROR: pg_hba.conf not found for cluster '${PG_VERSION:-none}'; cannot configure local trust" >&2; \ - exit 1; \ - fi && \ - echo "local all all trust" > "$PG_HBA" && \ - echo "host all all 127.0.0.1/32 trust" >> "$PG_HBA" && \ - echo "host all all ::1/128 trust" >> "$PG_HBA" - -# Use file-backed dynamic shared memory instead of POSIX /dev/shm. -# Cloudflare Containers provide only a tiny /dev/shm, and PostgreSQL's default -# dynamic_shared_memory_type = posix FATALs on startup there with -# "could not resize shared memory segment ... No space left on device", which -# kills the container before it can serve a port. mmap keeps DSM in the data -# directory ($PGDATA/pg_dynshmem), removing the /dev/shm dependency. Local -# Docker hides this because its default /dev/shm is 64MB. -# -# Select the highest-version cluster's config -- the same one the entrypoint -# starts (ls /etc/postgresql | sort -V | tail -1) -- so the override always -# lands on the cluster that actually runs, even if multiple majors are present. -RUN PG_VERSION="$(ls /etc/postgresql 2>/dev/null | sort -V | tail -1)" && \ - PG_CONF="/etc/postgresql/${PG_VERSION}/main/postgresql.conf" && \ - if [ -z "$PG_VERSION" ] || [ ! -f "$PG_CONF" ]; then \ - echo "ERROR: postgresql.conf not found for cluster '${PG_VERSION:-none}'; cannot disable /dev/shm DSM dependency" >&2; \ - exit 1; \ - fi && \ - sed -i 's/^[[:space:]]*dynamic_shared_memory_type[[:space:]]*=/# &/' "$PG_CONF" && \ - printf '\n# Preview: avoid /dev/shm dependency (small in Cloudflare Containers)\ndynamic_shared_memory_type = mmap\n' >> "$PG_CONF" && \ - grep -qx 'dynamic_shared_memory_type = mmap' "$PG_CONF" && \ - echo "Configured dynamic_shared_memory_type=mmap in $PG_CONF" - -# Create database directory with correct permissions -RUN mkdir -p /var/run/postgresql && \ - chown -R postgres:postgres /var/run/postgresql && \ - chmod 2775 /var/run/postgresql - -# Copy built artifacts -COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" -COPY --chown=rails:rails --from=build /rails /rails - -# Create preview entrypoint script inline -RUN cat > /rails/bin/preview-entrypoint << 'ENTRYPOINT_EOF' -#!/bin/bash -set -e - -cd /rails - -# Diagnostics posts are best-effort and must NEVER stall boot: the worker's -# Durable Object can be unresponsive while it waits for this container's port, -# so an unbounded curl here deadlocks startup against the port check. -emit_status_sync() { - if [ -n "$PREVIEW_ORIGIN" ] && [ -n "$PREVIEW_DIAGNOSTICS_NONCE" ]; then - local stage="$1" - local detail="$2" - local payload - payload=$(STAGE="$stage" DETAIL="$detail" ruby -rjson -e 'print JSON.generate({stage: ENV.fetch("STAGE"), detail: ENV.fetch("DETAIL", "")})' 2>/dev/null) || return 0 - curl -fsS --connect-timeout 2 --max-time 5 -X POST "$PREVIEW_ORIGIN/_container_event" \ - -H 'content-type: application/json' \ - -H "x-preview-diagnostics-nonce: $PREVIEW_DIAGNOSTICS_NONCE" \ - --data "$payload" >/dev/null || true - fi -} - -emit_status() { - emit_status_sync "$1" "$2" & -} - -summarize_log_tail() { - local file="$1" - local label="$2" - - if [ ! -f "$file" ]; then - printf '%s log unavailable' "$label" - return 0 - fi - - tail -n 80 "$file" 2>&1 | - sed 's/"/'"'"'/g' | - tr '\n' ' ' | - sed 's/ */ /g' | - cut -c 1-1600 -} - -fail_preview() { - local detail="$1" - trap - ERR - # Always log to stderr too: the HTTP diagnostics channel can be unreachable - # while the worker's Durable Object is still waiting for this container's - # port, but stderr is captured by Cloudflare container observability, so the - # real reason survives even when the event POST does not. - echo "PREVIEW FAILED: ${detail}" >&2 - emit_status_sync failed "$detail" - exit 1 -} - -postgres_cluster_snapshot() { - local snapshot="" - local cluster_status - local postgres_log - - if command -v pg_lsclusters >/dev/null 2>&1; then - cluster_status="$(pg_lsclusters 2>&1 | tr '\n' '|' | sed 's/"/'"'"'/g' | cut -c 1-500)" - snapshot="clusters=${cluster_status}" - fi - - postgres_log="/var/log/postgresql/postgresql-${PG_VERSION}-main.log" - if [ -f "$postgres_log" ]; then - snapshot="${snapshot} log=$(summarize_log_tail "$postgres_log" postgres)" - fi - - printf '%s' "$snapshot" -} - -trap 'emit_status_sync failed "preview-entrypoint failed on line ${LINENO}"' ERR -emit_status boot "preview-entrypoint started" - -# Bind :3000 immediately with a tiny placeholder responder. Cloudflare's -# container supervisor only waits ~20s for the port, while the full stack -# (redis, postgres, migrations) needs 60s+ on a basic instance. The -# placeholder answers 503 with a meta-refresh; the worker still gates -# previewReady on the real Rails /up probe and sample data, so readiness -# semantics are unchanged. It is replaced by the real server below. -ruby -rsocket -e ' - server = TCPServer.new("0.0.0.0", 3000) - body = "Sure preview warmingSure preview is booting (database setup in progress). This page refreshes automatically." - loop do - client = begin - server.accept - rescue StandardError - next - end - begin - # Never read from the client: the response is static, and a blocking read - # would let one silent connection (e.g. a bare TCP port probe) wedge this - # single-threaded loop and starve every later probe. - client.write("HTTP/1.1 503 Service Unavailable\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: #{body.bytesize}\r\nRetry-After: 3\r\nConnection: close\r\n\r\n#{body}") - rescue StandardError - ensure - begin - client.close - rescue StandardError - end - end - end -' >/tmp/port-placeholder.log 2>&1 & -PLACEHOLDER_PID=$! - -# Confirm the placeholder actually bound :3000 before reporting it. A plain -# TCP connect is used because the placeholder intentionally answers 503. -PLACEHOLDER_BOUND=0 -for i in {1..10}; do - if (exec 3<>/dev/tcp/127.0.0.1/3000) 2>/dev/null; then - PLACEHOLDER_BOUND=1 - break - fi - if ! kill -0 "$PLACEHOLDER_PID" 2>/dev/null; then - break - fi - sleep 0.2 -done -if [ "$PLACEHOLDER_BOUND" -eq 1 ]; then - emit_status port-placeholder "bound :3000 placeholder responder (pid ${PLACEHOLDER_PID})" -else - echo "Warning: :3000 placeholder did not come up; continuing boot" >&2 - emit_status port-placeholder-missing "placeholder responder failed to bind :3000: $(summarize_log_tail /tmp/port-placeholder.log port-placeholder)" -fi - -REDIS_READY=0 -POSTGRES_READY=0 - -# Start Redis -echo "Starting Redis..." -emit_status redis-start "starting redis" -sudo redis-server --daemonize yes --bind 127.0.0.1 - -# Wait for Redis to be ready -echo "Waiting for Redis to be ready..." -for i in {1..10}; do - if redis-cli ping > /dev/null 2>&1; then - echo "Redis is ready" - emit_status redis-ready "redis is ready" - REDIS_READY=1 - break - fi - sleep 1 -done - -if [ "$REDIS_READY" -ne 1 ]; then - fail_preview "redis did not become ready in time" -fi - -# Start PostgreSQL -echo "Starting PostgreSQL..." -emit_status postgres-start "starting postgres" -PG_VERSION=$(ls /etc/postgresql/ | sort -V | tail -1) -if [ -z "$PG_VERSION" ]; then - fail_preview "could not determine installed PostgreSQL version" -fi -if sudo pg_ctlcluster --skip-systemctl-redirect "$PG_VERSION" main status > /dev/null 2>&1; then - emit_status postgres-already-running "postgres cluster already running" -else - POSTGRES_START_LOG=/tmp/postgres-start.log - if ! sudo pg_ctlcluster --skip-systemctl-redirect "$PG_VERSION" main start >"$POSTGRES_START_LOG" 2>&1; then - fail_preview "pg_ctlcluster start failed: $(summarize_log_tail "$POSTGRES_START_LOG" pg_ctlcluster-start) | $(postgres_cluster_snapshot)" - fi -fi - -# Wait for PostgreSQL to be ready -echo "Waiting for PostgreSQL to be ready..." -for i in {1..30}; do - if pg_isready -h localhost -U postgres > /dev/null 2>&1; then - echo "PostgreSQL is ready" - emit_status postgres-ready "postgres is ready" - POSTGRES_READY=1 - break - fi - sleep 1 -done - -if [ "$POSTGRES_READY" -ne 1 ]; then - fail_preview "postgres did not become ready in time: $(postgres_cluster_snapshot)" -fi - -# Create database user and database if they don't exist -echo "Setting up database..." -emit_status db-setup "setting up database" -psql -h localhost -U postgres -tc "SELECT 1 FROM pg_roles WHERE rolname='rails'" | grep -q 1 || \ - psql -h localhost -U postgres -c "CREATE USER rails WITH SUPERUSER PASSWORD 'rails';" - -psql -h localhost -U postgres -tc "SELECT 1 FROM pg_database WHERE datname='sure_development'" | grep -q 1 || \ - psql -h localhost -U postgres -c "CREATE DATABASE sure_development OWNER rails;" - -# Set DATABASE_URL if not already set -export DATABASE_URL="${DATABASE_URL:-postgres://rails:rails@localhost:5432/sure_development}" - -# Set REDIS_URL if not already set -export REDIS_URL="${REDIS_URL:-redis://localhost:6379/0}" - -# Generate SECRET_KEY_BASE if not set -export SECRET_KEY_BASE="${SECRET_KEY_BASE:-$(openssl rand -hex 64)}" - -# Run database migrations -echo "Running database migrations..." -emit_status db-prepare "running rails db:prepare" -/rails/bin/rails db:prepare -emit_status db-prepare-done "rails db:prepare finished" - -# Defer all demo-data creation until after Rails is up so preview can boot first -echo "Checking demo dataset..." -emit_status demo-data-check "checking for default demo user" -DEMO_EMAIL="${DEMO_USER_EMAIL:-user@example.com}" -DEMO_EMAIL_SQL=${DEMO_EMAIL//\'/\'\'} -DEMO_SEED="${DEMO_DATA_SEED:-880}" -DEMO_HAS_USER=0 -DEMO_HAS_DATA=0 - -if psql "$DATABASE_URL" -tAc "SELECT 1 FROM users WHERE email = '${DEMO_EMAIL_SQL}' LIMIT 1" | grep -q 1; then - DEMO_HAS_USER=1 - emit_status demo-data-user-present "default demo user already exists" -fi - -if psql "$DATABASE_URL" -tAc "SELECT 1 FROM accounts a JOIN users u ON u.family_id = a.family_id WHERE u.email = '${DEMO_EMAIL_SQL}' LIMIT 1" | grep -q 1; then - DEMO_HAS_DATA=1 - emit_status demo-data-skip "demo financial data already exists" -else - emit_status demo-data-deferred "deferring demo data creation until after rails boot" -fi - -# Release :3000 for the real server. The brief listener gap is handled by the -# worker, which catches containerFetch errors and serves its wait page. -kill "$PLACEHOLDER_PID" 2>/dev/null || true -wait "$PLACEHOLDER_PID" 2>/dev/null || true - -# Execute the main command with an internal readiness probe -echo "Starting Rails server..." -emit_status rails-start "starting rails server" -"$@" > /tmp/rails.log 2>&1 & -RAILS_PID=$! - -for i in {1..180}; do - if curl -fsS http://127.0.0.1:3000/up > /dev/null 2>&1; then - emit_status rails-up-ready "rails responded on localhost:3000/up" - - if [ "$DEMO_HAS_USER" -ne 1 ] || [ "$DEMO_HAS_DATA" -ne 1 ]; then - emit_status demo-data-load "creating/backfilling demo dataset in background (seed=${DEMO_SEED})" - ( - ( - DEMO_USER_EMAIL="$DEMO_EMAIL" DEMO_DATA_SEED="$DEMO_SEED" /rails/bin/rails runner ' - email = ENV.fetch("DEMO_USER_EMAIL") - generator = Demo::Generator.new(seed: ENV.fetch("DEMO_DATA_SEED")) - user = User.find_by(email: email) - - unless user - generator.generate_empty_data!(skip_clear: true) - user = User.find_by!(email: email) - end - - has_accounts = user.family.accounts.exists? - generator.generate_new_user_data_for!(user.family, email: user.email) unless has_accounts - ' - ) > /tmp/demo-data.log 2>&1 && \ - emit_status demo-data-ready "default demo dataset loaded in background" || \ - emit_status demo-data-failed "background demo dataset load failed: $(summarize_log_tail /tmp/demo-data.log demo-data)" - ) & - fi - - break - fi - sleep 1 -done - -if ! curl -fsS http://127.0.0.1:3000/up > /dev/null 2>&1; then - emit_status rails-up-timeout "rails did not answer localhost:3000/up in time" - emit_status rails-process-status "$(ps -o pid=,ppid=,stat=,comm=,args= -p "$RAILS_PID" 2>/dev/null | tr -s ' ' | sed 's/^ //')" - emit_status rails-process-wchan "$(cat /proc/$RAILS_PID/wchan 2>/dev/null | tr '\n' ' ' | cut -c 1-200)" - emit_status rails-process-children "$(ps -o pid=,ppid=,stat=,comm=,args= --ppid "$RAILS_PID" 2>/dev/null | tail -n +2 | tr '\n' '|' | cut -c 1-600)" - emit_status rails-socket-state "$(ruby -e 'hex="0BB8"; rows=File.readlines("/proc/net/tcp")+File.readlines("/proc/net/tcp6"); hits=rows.select{|l| l.include?(":#{hex} ")}.map{|l| l.strip.split[3] rescue nil}.compact; puts(hits.empty? ? "no-listener" : hits.join(","))' 2>&1 | tr '\n' ' ' | cut -c 1-400)" - emit_status rails-log-tail "$(tail -n 40 /tmp/rails.log 2>&1 | sed 's/"/'"'"'/g' | tr '\n' ' ' | cut -c 1-1200)" -fi - -wait "$RAILS_PID" -ENTRYPOINT_EOF -RUN chmod 755 /rails/bin/preview-entrypoint && chown rails:rails /rails/bin/preview-entrypoint - -USER 1000:1000 - -ENTRYPOINT ["/rails/bin/preview-entrypoint"] - -EXPOSE 3000 -CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"] diff --git a/bin/preview_deploy_security_check.rb b/bin/preview_deploy_security_check.rb deleted file mode 100644 index e7fedb0ca..000000000 --- a/bin/preview_deploy_security_check.rb +++ /dev/null @@ -1,531 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -%w[json pathname yaml].each { |library| require library } - -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") -CONFIG_RENDERER_PATH = File.join(ROOT, "workers/preview/deploy/render_preview_config.cjs") -REDACTION_HELPER_PATH = File.join(ROOT, "workers/preview/deploy/redact_preview_log.sh") -PREVIEW_WORKER_PATH = File.join(ROOT, "workers/preview/src/index.ts") -PREVIEW_DOCKERFILE_PATH = File.join(ROOT, "Dockerfile.preview") -PINNED_ACTION = /\A[^@\s]+@[a-f0-9]{40}\z/ -EXPECTED_ACTION_PINS = { - "actions/checkout" => "93cb6efe18208431cddfb8368fd83d5badbf9bfd", # v5 - "actions/download-artifact" => "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", # v6 - "actions/github-script" => "ed597411d8f924073f98dfc5c65a23a2325f34cd", # v8 - "actions/setup-node" => "48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e", # v6 - "actions/upload-artifact" => "b7c566a772e6b6bfb58ed0dc250532a479d7789f" # v6 -}.freeze -INLINE_SECRET_EXPRESSION = /\$\{\{\s*secrets\s*(?:\.|\[)/i -INLINE_PR_EXPRESSION = / - \$\{\{\s* - github\s* - (?:\.\s*event|\[\s*['"]event['"]\s*\])\s* - (?:\.\s*pull_request|\[\s*['"]pull_request['"]\s*\]) -/ix -PR_CONTROLLED_WORKDIR = %r{\A(?:pr|workers/preview)(?:/|\z)} -GITHUB_WORKSPACE_PREFIX = %r{ - \A - (?: - \$GITHUB_WORKSPACE | - \$\{\{\s*github\s*(?:\.\s*workspace|\[\s*['"]workspace['"]\s*\])\s*\}\} - ) - (?:/|\z) -}ix -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", - "pull-requests" => "write" -}.freeze -EXPECTED_DEPLOY_SECRET_ENV = %w[CLOUDFLARE_ACCOUNT_ID CLOUDFLARE_API_TOKEN CLOUDFLARE_WORKERS_SUBDOMAIN].freeze -EXPECTED_PUSH_SECRET_ENV = %w[CLOUDFLARE_ACCOUNT_ID CLOUDFLARE_API_TOKEN].freeze -EXPECTED_DIAGNOSTICS_PATH = "${{ runner.temp }}/preview-diagnostics" -EXPECTED_FAILURE_DIAGNOSTICS_PATH = "${{ runner.temp }}/preview-failure-diagnostics" -EXPECTED_CLEANUP_METADATA_PATH = "${{ runner.temp }}/preview-cleanup-metadata/wrangler.toml" -REQUIRED_PREPARE_LINES = [ - 'cp trusted/workers/preview/package.json "$preview_dir/package.json"', - 'cp trusted/workers/preview/package-lock.json "$preview_dir/package-lock.json"', - 'cp trusted/workers/preview/tsconfig.json "$preview_dir/tsconfig.json"', - 'cp trusted/workers/preview/wrangler.toml "$preview_dir/wrangler.toml"', - 'cp -R trusted/workers/preview/src "$preview_dir/src"', - 'mkdir -p "$preview_dir/deploy"', - 'cp trusted/workers/preview/deploy/redact_preview_log.sh "$preview_dir/deploy/redact_preview_log.sh"', - 'cp trusted/workers/preview/deploy/render_preview_config.cjs "$preview_dir/deploy/render_preview_config.cjs"', - 'chmod 0755 "$preview_dir/deploy/redact_preview_log.sh"', - 'diagnostics_nonce="$(openssl rand -hex 32)"', - 'sed -i "s/\${PREVIEW_DIAGNOSTICS_NONCE}/${diagnostics_nonce}/g" "$preview_dir/src/index.ts"', - 'cp "$preview_dir/wrangler.toml" "$preview_dir/wrangler.source.toml"', - "Preview diagnostics nonce placeholder was not replaced", - "npm ci --ignore-scripts --no-audit --no-fund" -].freeze -REQUIRED_IMAGE_BUILD_LINES = [ - "docker build", - "--platform linux/amd64", - '--build-arg "BUILD_COMMIT_SHA=${HEAD_SHA}"', - "-f Dockerfile.preview", - '-t "${IMAGE_TAG}"', - 'docker save "${IMAGE_TAG}" | gzip -1 > "$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) - warn "preview-deploy security check failed: #{message}" - exit 1 -end - -def assert(value, message) - fail_check(message) unless value -end - -def workflow_on(workflow) - workflow["on"] || workflow[true] || fail_check("workflow is missing on trigger") -end - -def step!(steps, name) - steps.find { |step| step["name"] == name } || fail_check("missing #{name.inspect} step") -end - -def run(step) - step.fetch("run", "") -end - -def step_body(step) - [ run(step), step.dig("with", "script") ].compact.join("\n") -end - -def env_hash(node) - node.fetch("env", {}) -end - -def assert_run_includes(step, *needles) - script = step_body(step) - needles.each { |needle| assert(script.include?(needle), "#{step["name"]} must include #{needle.inspect}") } - script -end - -def normalized_working_directory(value) - path = value.to_s.strip.sub(GITHUB_WORKSPACE_PREFIX, "") - normalized = Pathname.new(path).cleanpath.to_s - - normalized == "." ? "" : normalized -end - -def environment_name(job) - environment = job["environment"] - environment.is_a?(Hash) ? environment["name"] : environment -end - -def assert_pinned_actions!(steps) - steps.each do |step| - uses = step["uses"] - next unless uses - next if uses.start_with?("./") - - assert(uses.match?(PINNED_ACTION), "#{step["name"] || uses} must pin external actions") - - action, sha = uses.split("@", 2) - expected_sha = EXPECTED_ACTION_PINS[action] - assert(sha == expected_sha, "#{step["name"] || uses} must pin #{action} to #{expected_sha}") if expected_sha - end -end - -def assert_no_inline_expressions!(steps) - inline_scripts = steps.flat_map { |step| [ run(step), step.dig("with", "script") ] }.compact.join("\n") - assert(!inline_scripts.match?(INLINE_SECRET_EXPRESSION), "secrets must enter scripts through env") - assert(!inline_scripts.match?(INLINE_PR_EXPRESSION), "PR fields must enter scripts through env") -end - -def assert_secret_env_sources!(step, expected_keys) - env = step.fetch("env") - - assert(env.keys.sort == expected_keys, "#{step["name"]} secret env keys must be #{expected_keys.inspect}") - assert(expected_keys.all? { |name| env.fetch(name).start_with?("${{ secrets.") }, "#{step["name"]} secret env must be sourced from GitHub secrets") -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) -config_renderer_script = File.read(CONFIG_RENDERER_PATH) -redaction_helper_script = File.read(REDACTION_HELPER_PATH) -preview_worker_script = File.read(PREVIEW_WORKER_PATH) -preview_dockerfile = File.read(PREVIEW_DOCKERFILE_PATH) - -preview_on = workflow_on(preview_workflow) -pr_on = workflow_on(pr_workflow) -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") -prepare = step!(deploy_steps, "Prepare trusted preview deploy workspace") -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") -collect_failure_diagnostics = step!(deploy_steps, "Collect preview failure diagnostics") -upload_failure_diagnostics = step!(deploy_steps, "Upload preview failure diagnostics") -prepare_cleanup_metadata = step!(deploy_steps, "Prepare cleanup metadata") -store_cleanup_metadata = step!(deploy_steps, "Store cleanup metadata") -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", "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 ], - [ "PR workflow jobs", pr_jobs.keys, [ "ci", "preview_image" ] ], - [ "preview gate permissions", gate_job.fetch("permissions"), EXPECTED_GATE_PERMISSIONS ], - [ "preview gate timeout", gate_job.fetch("timeout-minutes"), 10 ], - [ "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 gate resolution source output", gate_job.dig("outputs", "resolution_source"), "${{ steps.preview.outputs.resolution_source }}" ], - [ "preview image needs", image_job.fetch("needs"), "ci" ], - [ "preview image permissions", image_job.fetch("permissions"), EXPECTED_IMAGE_PERMISSIONS ], - [ "preview image timeout", image_job.fetch("timeout-minutes"), 30 ], - [ "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 }}" ], - [ "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 }}" ], - [ "deploy RESOLUTION_SOURCE env", deploy_job.dig("env", "RESOLUTION_SOURCE"), "${{ needs.preview-gate.outputs.resolution_source }}" ], - [ "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 ], - [ "trusted checkout ref", trusted_checkout.dig("with", "ref"), "${{ github.event.repository.default_branch }}" ], - [ "trusted checkout path", trusted_checkout.dig("with", "path"), "trusted" ], - [ "trusted checkout credentials", trusted_checkout.dig("with", "persist-credentials"), false ], - [ "download artifact name", download_artifact.dig("with", "name"), "${{ env.ARTIFACT_NAME }}" ], - [ "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 if", upload_diagnostics.fetch("if"), "always() && steps.deploy.outputs.preview_url != ''" ], - [ "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"), EXPECTED_DIAGNOSTICS_PATH ], - [ "diagnostics upload retention", upload_diagnostics.dig("with", "retention-days"), 3 ], - [ "failure diagnostics collect if", collect_failure_diagnostics.fetch("if"), "failure()" ], - [ "failure diagnostics upload if", upload_failure_diagnostics.fetch("if"), "failure()" ], - [ "failure diagnostics upload name", upload_failure_diagnostics.dig("with", "name"), "preview-failure-diagnostics-pr-${{ env.PR_NUMBER }}-${{ env.HEAD_SHA }}" ], - [ "failure diagnostics upload path", upload_failure_diagnostics.dig("with", "path"), EXPECTED_FAILURE_DIAGNOSTICS_PATH ], - [ "failure diagnostics upload retention", upload_failure_diagnostics.dig("with", "retention-days"), 3 ], - [ "cleanup metadata prepare if", prepare_cleanup_metadata.fetch("if"), "success()" ], - [ "cleanup metadata upload if", store_cleanup_metadata.fetch("if"), "success()" ], - [ "cleanup metadata upload name", store_cleanup_metadata.dig("with", "name"), "preview-cleanup-pr-${{ env.PR_NUMBER }}" ], - [ "cleanup metadata upload path", store_cleanup_metadata.dig("with", "path"), EXPECTED_CLEANUP_METADATA_PATH ], - [ "cleanup metadata upload retention", store_cleanup_metadata.dig("with", "retention-days"), 2 ], - [ "Wrangler binary", wrangler.dig("bin", "wrangler"), "bin/wrangler.js" ] -].each { |label, actual, expected| assert(actual == expected, "#{label}: expected #{actual.inspect} to equal #{expected.inspect}") } - -assert(pr_on.key?("pull_request"), "PR workflow must still run on pull_request") -assert(!preview_on.key?("pull_request_target"), "privileged preview deploy workflow must not run on pull_request_target") -assert(!preview_on.key?("pull_request"), "privileged preview deploy workflow must not run directly on pull_request") -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").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([ 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") -required_deploy_order = [ - trusted_checkout, - download_artifact, - verify_checksum, - prepare, - load_image, - push_image, - configure_image, - deploy, - warm_preview, - collect_diagnostics, - upload_diagnostics, - collect_failure_diagnostics, - upload_failure_diagnostics, - prepare_cleanup_metadata, - store_cleanup_metadata -] -assert( - required_deploy_order.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 + 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, - "require('./trusted-preview-resolver/workers/preview/deploy/resolve_preview_request.cjs')", - "resolvePreviewRequest({ github, context, core })" -) - -[ - "workflowRun.pull_requests?.[0]", - "github.rest.repos.listPullRequestsAssociatedWithCommit", - "parsePreviewArtifactName", - "artifactPullRequestNumbers.length === 1", - "conflicts with workflow_run PR", - "conflicts with commit-associated PRs", - "pullRequest.head.sha !== headSha", - "is stale for PR", - "preview-cf", - "filename.startsWith(\".github/workflows/\")", - "preview-image-pr-${prNumber}-${headSha}", - "!item.expired", - "core.setOutput(\"artifact_name\", artifactName)", - "core.setOutput(\"is_fork\", String(isFork))", - "core.setOutput(\"resolution_source\", selected.source)", - "core.setOutput(\"should_deploy\", \"true\")" -].each { |needle| assert(resolver_script.include?(needle), "preview resolver must include #{needle.inspect}") } - -artifact_resolution_index = resolver_script.index("artifactPullRequestNumbers.length === 1") -workflow_run_fallback_index = resolver_script.index("if (workflowRunPullRequestNumber)") -assert(artifact_resolution_index, "preview resolver must inspect exact preview artifacts") -assert(workflow_run_fallback_index, "preview resolver must retain workflow_run PR fallback") -assert( - artifact_resolution_index < workflow_run_fallback_index, - "preview resolver must prefer exact preview artifacts before workflow_run PR metadata" -) -assert(File.executable?(REDACTION_HELPER_PATH), "preview log redaction helper must be executable") -[ - "registry\\.cloudflare\\.com/[^/]+/", - "Authorization|Proxy-Authorization", - "X-Auth-Key|X-Auth-Email|X-Api-Key|Api-Key", - "api_key|access_token|refresh_token|auth_token|key|private_key", - "CLOUDFLARE_ACCOUNT_ID=", - "CLOUDFLARE_API_TOKEN|API_KEY|ACCESS_TOKEN|REFRESH_TOKEN|AUTH_TOKEN|PRIVATE_KEY", - "", - "" -].each { |needle| assert(redaction_helper_script.include?(needle), "preview log redaction helper must include #{needle.inspect}") } - -[ - "REGISTRY_IMAGE_REF_PATTERN", - "REGISTRY_IMAGE_REF_SCAN_PATTERN", - "function validateRegistryImageRef", - "function renderPreviewConfig", - "function findRegistryImageRef", - "Expected wrangler.toml source to contain exactly one image entry", - "Cloudflare registry image reference does not match this preview artifact", - "Cloudflare registry image reference account does not match this workflow", - "module.exports" -].each { |needle| assert(config_renderer_script.include?(needle), "preview config renderer 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") -assert(!prepare_run.include?("CLOUDFLARE_API_TOKEN"), "prepare step must not receive Cloudflare secrets") -assert(prepare_run.include?('preview_dir="$RUNNER_TEMP/sure-preview-worker"'), "trusted workspace must be created under RUNNER_TEMP") -assert(deploy_steps.select { |step| run(step).match?(/npm (ci|install)/) }.map { |step| step["name"] } == [ prepare["name"] ], "only prepare may install deploy tooling") - -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" < kept.has(record))", - 'const PREVIEW_DIAGNOSTICS_NONCE = "${PREVIEW_DIAGNOSTICS_NONCE}"', - "PREVIEW_DIAGNOSTICS_NONCE", - 'request.headers.get("x-preview-diagnostics-nonce")', - "return new Response(\"not found\", { status: 404 })", - "timings: PreviewTimings", - "buildPreviewTimings", - "const previewReady = sampleDataReady && railsResponding", - "previewReadyAt", - "secondsToRailsReady", - "secondsToDemoDataReady", - "secondsFromRailsReadyToDemoDataReady", - "secondsToPreviewReady" -].each { |needle| assert(preview_worker_script.include?(needle), "preview worker must include #{needle.inspect}") } - -[ - '[ -n "$PREVIEW_ORIGIN" ] && [ -n "$PREVIEW_DIAGNOSTICS_NONCE" ]', - '-H "x-preview-diagnostics-nonce: $PREVIEW_DIAGNOSTICS_NONCE"' -].each { |needle| assert(preview_dockerfile.include?(needle), "preview Dockerfile entrypoint must include #{needle.inspect}") } - -secret_steps = deploy_steps.select { |step| env_hash(step).then { |env| env.key?("CLOUDFLARE_API_TOKEN") || env.key?("CLOUDFLARE_ACCOUNT_ID") } } -assert(secret_steps.map { |step| step["name"] } == [ push_image["name"], deploy["name"] ], "only image push and deploy may receive Cloudflare secrets") -assert_secret_env_sources!(push_image, EXPECTED_PUSH_SECRET_ENV) -assert_secret_env_sources!(deploy, EXPECTED_DEPLOY_SECRET_ENV) -secret_steps.each do |step| - assert(step["working-directory"].nil?, "#{step["name"]} must not run from a PR-controlled working directory") - assert(!run(step).match?(/npx wrangler|npm (ci|install)|docker build|docker save|docker run/), "#{step["name"]} must not execute PR-controlled build or package tooling with secrets") -end - -puts "preview-deploy security check passed" diff --git a/test/javascript/preview_deploy/render_preview_config_test.cjs b/test/javascript/preview_deploy/render_preview_config_test.cjs deleted file mode 100644 index 70deab73f..000000000 --- a/test/javascript/preview_deploy/render_preview_config_test.cjs +++ /dev/null @@ -1,100 +0,0 @@ -const assert = require("node:assert/strict"); -const { describe, it } = require("node:test"); - -const { - findRegistryImageRef, - renderPreviewConfig, - validateRegistryImageRef, -} = require("../../../workers/preview/deploy/render_preview_config.cjs"); - -const options = { - accountId: "account_123", - prNumber: "2160", - headSha: "3f013c4d9193ff111295c89a6f833d59bd69d91e", -}; -const imageRef = - "registry.cloudflare.com/account_123/sure-preview-pr-2160:3f013c4d9193ff111295c89a6f833d59bd69d91e"; - -describe("renderPreviewConfig", () => { - it("renders exactly one trusted TOML image entry to a registry reference", () => { - const source = [ - 'name = "sure-preview-2160"', - "", - "[[containers]]", - 'image = "../../Dockerfile.preview"', - 'class_name = "RailsContainer"', - "", - ].join("\n"); - - const rendered = renderPreviewConfig(source, imageRef, options); - - assert.ok(rendered.includes(`image = "${imageRef}"`)); - assert.doesNotMatch(rendered, /Dockerfile\.preview/); - }); - - it("rejects missing image entries", () => { - assert.throws( - () => renderPreviewConfig('name = "sure-preview-2160"\n', imageRef, options), - /contain an image entry/ - ); - }); - - it("rejects duplicate image entries", () => { - const source = [ - "[[containers]]", - 'image = "../../Dockerfile.preview"', - "", - "[[containers]]", - 'image = "../../OtherDockerfile"', - "", - ].join("\n"); - - assert.throws(() => renderPreviewConfig(source, imageRef, options), /exactly one image entry/); - }); - - it("rejects local Docker tags as deploy image refs", () => { - assert.throws( - () => renderPreviewConfig('image = "../../Dockerfile.preview"\n', "my-local-image:latest", options), - /Cloudflare registry image reference/ - ); - }); -}); - -describe("validateRegistryImageRef", () => { - it("accepts the expected registry ref", () => { - assert.equal(validateRegistryImageRef(imageRef, options), imageRef); - }); - - it("rejects registry refs for another PR", () => { - const wrongPr = - "registry.cloudflare.com/account_123/sure-preview-pr-2161:3f013c4d9193ff111295c89a6f833d59bd69d91e"; - - assert.throws(() => validateRegistryImageRef(wrongPr, options), /does not match this preview artifact/); - }); - - it("rejects registry refs for another account", () => { - const wrongAccount = - "registry.cloudflare.com/account_456/sure-preview-pr-2160:3f013c4d9193ff111295c89a6f833d59bd69d91e"; - - assert.throws(() => validateRegistryImageRef(wrongAccount, options), /account does not match/); - }); -}); - -describe("findRegistryImageRef", () => { - it("extracts the expected registry image ref from wrangler output", () => { - const log = [ - "Pushing image layers", - "Published registry.cloudflare.com/account_123/sure-preview-pr-2160:3f013c4d9193ff111295c89a6f833d59bd69d91e", - "Done", - ].join("\n"); - - assert.equal(findRegistryImageRef(log, options), imageRef); - }); - - it("ignores registry refs that do not match this preview artifact", () => { - const log = - "Published registry.cloudflare.com/account_123/sure-preview-pr-2161:3f013c4d9193ff111295c89a6f833d59bd69d91e"; - - assert.equal(findRegistryImageRef(log, options), ""); - }); -}); diff --git a/test/javascript/preview_deploy/resolve_preview_request_test.cjs b/test/javascript/preview_deploy/resolve_preview_request_test.cjs deleted file mode 100644 index a3fb32a1f..000000000 --- a/test/javascript/preview_deploy/resolve_preview_request_test.cjs +++ /dev/null @@ -1,345 +0,0 @@ -const assert = require("node:assert/strict"); -const { describe, it } = require("node:test"); - -const { - parsePreviewArtifactName, - resolvePreviewRequest, - selectPullRequestNumber, -} = require("../../../workers/preview/deploy/resolve_preview_request.cjs"); - -function contextFor(workflowRun) { - return { - repo: { - owner: "we-promise", - repo: "sure", - }, - payload: { - workflow_run: workflowRun, - }, - }; -} - -function previewArtifact(prNumber, headSha, extra = {}) { - return { - name: `preview-image-pr-${prNumber}-${headSha}`, - expired: false, - ...extra, - }; -} - -function openPullRequest(number, headSha, fullName = "we-promise/sure", extra = {}) { - return { - number, - state: "open", - labels: [{ name: "preview-cf" }], - head: { - sha: headSha, - repo: { - full_name: fullName, - }, - }, - base: { - repo: { - full_name: "we-promise/sure", - }, - }, - ...extra, - }; -} - -function fakeGithub({ artifacts = [], associatedPullRequests = [], pullRequest, files = [] }) { - return { - paginate: async (endpoint, params) => { - 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("prefers the preview artifact when commit association matches", () => { - const selected = selectPullRequestNumber({ - runPullRequest: undefined, - artifacts: [previewArtifact(2017, headSha)], - associatedPullRequests: [openPullRequest(2017, headSha, "Rene0422/sure")], - context, - headSha, - }); - - assert.deepEqual(selected, { - prNumber: 2017, - source: "artifact_name+commit_association", - }); - }); - - it("records workflow_run as a corroborating source when it matches the preview artifact", () => { - const selected = selectPullRequestNumber({ - runPullRequest: { number: 2017 }, - artifacts: [previewArtifact(2017, headSha)], - associatedPullRequests: [], - context, - headSha, - }); - - assert.deepEqual(selected, { - prNumber: 2017, - source: "artifact_name+workflow_run", - }); - }); - - it("records workflow_run and commit association when both match the preview artifact", () => { - const selected = selectPullRequestNumber({ - runPullRequest: { number: 2017 }, - artifacts: [previewArtifact(2017, headSha)], - associatedPullRequests: [openPullRequest(2017, headSha, "Rene0422/sure")], - context, - headSha, - }); - - assert.deepEqual(selected, { - prNumber: 2017, - source: "artifact_name+workflow_run+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_name+commit_association", - }); - }); - - it("fails closed when workflow metadata disagrees with the preview artifact", () => { - const selected = selectPullRequestNumber({ - runPullRequest: { number: 1985 }, - artifacts: [previewArtifact(1798, headSha)], - associatedPullRequests: [openPullRequest(1798, headSha)], - context, - headSha, - }); - - assert.equal(selected.prNumber, undefined); - assert.equal(typeof selected.error, "string"); - assert.match(selected.error, /conflicts with workflow_run PR 1985/); - }); - - it("fails closed when commit association disagrees with the preview artifact", () => { - const selected = selectPullRequestNumber({ - runPullRequest: undefined, - artifacts: [previewArtifact(1798, headSha)], - associatedPullRequests: [openPullRequest(1985, headSha)], - context, - headSha, - }); - - assert.equal(selected.prNumber, undefined); - assert.equal(typeof selected.error, "string"); - assert.match(selected.error, /conflicts with commit-associated PRs 1985/); - }); - - 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"); - assert.equal(state.outputs.resolution_source, "artifact_name+commit_association"); - }); - - 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.equal(state.outputs.resolution_source, "artifact_name"); - 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/redact_preview_log.sh b/workers/preview/deploy/redact_preview_log.sh deleted file mode 100755 index 40633ff4e..000000000 --- a/workers/preview/deploy/redact_preview_log.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -perl -pe ' - s#registry\.cloudflare\.com/[^/]+/#registry.cloudflare.com//#g; - s#((?:Authorization|Proxy-Authorization):\s*Bearer\s+)[^[:space:]]+#$1#gi; - s#((?:X-Auth-Key|X-Auth-Email|X-Api-Key|Api-Key):\s*)[^[:space:]]+#$1#gi; - s#([?&](?:token|api_key|access_token|refresh_token|auth_token|key|private_key)=)[^&[:space:]]+#$1#gi; - s#("(?:token|api_key|access_token|refresh_token|auth_token|secret|client_secret|private_key)"\s*:\s*")[^"]*#$1#gi; - s#(CLOUDFLARE_ACCOUNT_ID=)[^[:space:]]+#$1#g; - s#((?:CLOUDFLARE_API_TOKEN|API_KEY|ACCESS_TOKEN|REFRESH_TOKEN|AUTH_TOKEN|PRIVATE_KEY)=)[^[:space:]]+#$1#g; -' diff --git a/workers/preview/deploy/render_preview_config.cjs b/workers/preview/deploy/render_preview_config.cjs deleted file mode 100644 index 4afeed72e..000000000 --- a/workers/preview/deploy/render_preview_config.cjs +++ /dev/null @@ -1,114 +0,0 @@ -const fs = require("node:fs"); - -const IMAGE_FIELD_PATTERN = /^(\s*image\s*=\s*)"([^"]*)"(\s*(?:#.*)?)$/gm; -const REGISTRY_IMAGE_REF_PATTERN = - /^registry\.cloudflare\.com\/([A-Za-z0-9_-]+)\/(sure-preview-pr-([1-9][0-9]*):([a-f0-9]{40}))$/; -const REGISTRY_IMAGE_REF_SCAN_PATTERN = - /registry\.cloudflare\.com\/[A-Za-z0-9_-]+\/sure-preview-pr-[1-9][0-9]*:[a-f0-9]{40}/g; - -function expectedImageTag({ prNumber, headSha }) { - if (!/^[1-9][0-9]*$/.test(String(prNumber || ""))) { - throw new Error("Expected a numeric preview PR number"); - } - - if (!/^[a-f0-9]{40}$/.test(String(headSha || ""))) { - throw new Error("Expected a 40-character preview head SHA"); - } - - return `sure-preview-pr-${prNumber}:${headSha}`; -} - -function validateRegistryImageRef(imageRef, { accountId, prNumber, headSha }) { - const match = REGISTRY_IMAGE_REF_PATTERN.exec(imageRef || ""); - if (!match) { - throw new Error("Expected a Cloudflare registry image reference"); - } - - const expectedTag = expectedImageTag({ prNumber, headSha }); - if (match[2] !== expectedTag) { - throw new Error("Cloudflare registry image reference does not match this preview artifact"); - } - - if (accountId && match[1] !== accountId) { - throw new Error("Cloudflare registry image reference account does not match this workflow"); - } - - return imageRef; -} - -function renderPreviewConfig(source, imageRef, options) { - validateRegistryImageRef(imageRef, options); - - const matches = [...source.matchAll(IMAGE_FIELD_PATTERN)]; - if (matches.length === 0) { - throw new Error("Expected wrangler.toml source to contain an image entry"); - } - - if (matches.length > 1) { - throw new Error("Expected wrangler.toml source to contain exactly one image entry"); - } - - return source.replace(IMAGE_FIELD_PATTERN, `$1${JSON.stringify(imageRef)}$3`); -} - -function findRegistryImageRef(log, options) { - const matches = [...new Set(log.match(REGISTRY_IMAGE_REF_SCAN_PATTERN) || [])]; - const matchedRef = matches.find((candidate) => { - try { - validateRegistryImageRef(candidate, options); - return true; - } catch { - return false; - } - }); - - return matchedRef || ""; -} - -function envOptions() { - return { - accountId: process.env.CLOUDFLARE_ACCOUNT_ID, - prNumber: process.env.PR_NUMBER, - headSha: process.env.HEAD_SHA, - }; -} - -function runCli() { - const command = process.argv[2]; - - if (command === "render") { - const sourcePath = process.argv[3]; - const destinationPath = process.argv[4]; - const imageRef = process.env.PREVIEW_IMAGE_REF; - - if (!sourcePath || !destinationPath) { - throw new Error("Usage: render_preview_config.cjs render "); - } - - const rendered = renderPreviewConfig(fs.readFileSync(sourcePath, "utf8"), imageRef, envOptions()); - fs.writeFileSync(destinationPath, rendered); - return; - } - - if (command === "find") { - const logPath = process.argv[3]; - if (!logPath) { - throw new Error("Usage: render_preview_config.cjs find "); - } - - process.stdout.write(findRegistryImageRef(fs.readFileSync(logPath, "utf8"), envOptions())); - return; - } - - throw new Error(`Unknown command ${command || ""}`); -} - -if (require.main === module) { - runCli(); -} - -module.exports = { - findRegistryImageRef, - renderPreviewConfig, - validateRegistryImageRef, -}; diff --git a/workers/preview/deploy/resolve_preview_request.cjs b/workers/preview/deploy/resolve_preview_request.cjs deleted file mode 100644 index 596a1e3c0..000000000 --- a/workers/preview/deploy/resolve_preview_request.cjs +++ /dev/null @@ -1,230 +0,0 @@ -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 uniquePullRequestNumbers(pullRequests) { - return [...new Set(pullRequests.map((pullRequest) => pullRequest.number))]; -} - -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 }) { - const associatedHeadPullRequests = associatedPullRequestsForHead(associatedPullRequests, context, headSha); - const associatedPullRequestNumbers = uniquePullRequestNumbers(associatedHeadPullRequests); - const artifactPullRequestNumbers = uniqueNumbers(artifactCandidates(artifacts, headSha)); - const workflowRunPullRequestNumber = runPullRequest?.number ?? null; - - if (artifactPullRequestNumbers.length > 1) { - return { - error: `Workflow run ${headSha} published preview artifacts for multiple pull requests`, - }; - } - - if (artifactPullRequestNumbers.length === 1) { - const artifactPullRequestNumber = artifactPullRequestNumbers[0]; - - if (workflowRunPullRequestNumber && workflowRunPullRequestNumber !== artifactPullRequestNumber) { - return { - error: `Preview artifact PR ${artifactPullRequestNumber} conflicts with workflow_run PR ${workflowRunPullRequestNumber}`, - }; - } - - if ( - associatedPullRequestNumbers.length > 0 && - !associatedPullRequestNumbers.includes(artifactPullRequestNumber) - ) { - return { - error: `Preview artifact PR ${artifactPullRequestNumber} conflicts with commit-associated PRs ${associatedPullRequestNumbers.join(", ")}`, - }; - } - - const corroboratingSources = []; - if (workflowRunPullRequestNumber === artifactPullRequestNumber) corroboratingSources.push("workflow_run"); - if (associatedPullRequestNumbers.includes(artifactPullRequestNumber)) { - corroboratingSources.push("commit_association"); - } - - return { - prNumber: artifactPullRequestNumber, - source: - corroboratingSources.length > 0 - ? `artifact_name+${corroboratingSources.join("+")}` - : "artifact_name", - }; - } - - if (workflowRunPullRequestNumber) { - if ( - associatedPullRequestNumbers.length > 0 && - !associatedPullRequestNumbers.includes(workflowRunPullRequestNumber) - ) { - return { - error: `workflow_run PR ${workflowRunPullRequestNumber} conflicts with commit-associated PRs ${associatedPullRequestNumbers.join(", ")}`, - }; - } - - return { - prNumber: workflowRunPullRequestNumber, - source: "workflow_run", - }; - } - - if (associatedHeadPullRequests.length === 1) { - return { - prNumber: associatedHeadPullRequests[0].number, - source: "commit_association", - }; - } - - if (associatedHeadPullRequests.length > 1) { - return { - error: `Workflow run head SHA ${headSha} is associated with multiple open pull requests and no single preview artifact matched`, - }; - } - - 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("resolution_source", selected.source); - core.setOutput("should_deploy", "true"); -} - -module.exports = { - artifactCandidates, - associatedPullRequestsForHead, - parsePreviewArtifactName, - resolvePreviewRequest, - selectPullRequestNumber, -}; diff --git a/workers/preview/package-lock.json b/workers/preview/package-lock.json deleted file mode 100644 index ffa57e247..000000000 --- a/workers/preview/package-lock.json +++ /dev/null @@ -1,1584 +0,0 @@ -{ - "name": "sure-preview-worker", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "sure-preview-worker", - "version": "1.0.0", - "devDependencies": { - "@cloudflare/containers": "^0.3.3", - "@cloudflare/workers-types": "^4.20250124.0", - "typescript": "^5.0.0", - "wrangler": "^4.103.0" - } - }, - "node_modules/@cloudflare/containers": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@cloudflare/containers/-/containers-0.3.3.tgz", - "integrity": "sha512-ZSXmArCoo5bVTp8pGAJdl5WKmwtZDcffJqr4JcZEbSmMIFjU+AlBqgysuxXMgu03Rp239cOdqerbjK7H0K2krQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", - "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", - "dev": true, - "license": "MIT OR Apache-2.0", - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@cloudflare/unenv-preset": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", - "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", - "dev": true, - "license": "MIT OR Apache-2.0", - "peerDependencies": { - "unenv": "2.0.0-rc.24", - "workerd": ">1.20260305.0 <2.0.0-0" - }, - "peerDependenciesMeta": { - "workerd": { - "optional": true - } - } - }, - "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260617.1.tgz", - "integrity": "sha512-jWwmgEVVWbsHNrLSNXzwjJaH90VzRxq1cWkQFUidxyeUPnMxemeNE8I9qFAfrpzGgE11e9sKDcE3ettJW08swQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260617.1.tgz", - "integrity": "sha512-LHH7b565g9znfCUOkwbec6FG2rmRbsgCy6aJiU9KN662mNheWl5sw/iKleiFSiljPKQQP3HkjnC/NSkdgi/aSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260617.1.tgz", - "integrity": "sha512-FMnaAKXe4Cfd8TQurCVd9fs2XQVBFRCsP+Id/SRdUv89MlwYu9zXfoyx6BxM+brPTIUK38SHbo8iaxiwzLi9JQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260617.1.tgz", - "integrity": "sha512-MRoifFYcqbxxIIQy7PqO5tFY/qPFSnjXzakWl0sO93l+HLyG35jRAgOi6jfqa4kBxc7gKKtH861DcewjxUfkjA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260617.1.tgz", - "integrity": "sha512-rgBV9wQrv0OSKgCTTbhFUFY3sLGNANZ88aqaLvtmEn2gmbFVb1J4PDGochVUdB7NSEp4D/ghHva6/8SZmbONpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workers-types": { - "version": "4.20260619.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260619.1.tgz", - "integrity": "sha512-bprsNzG0DapQPFwU2AvQlQ6FUO7Y4bKWaPBzLNI7nBSqlHsK0P62xx2NaNT6i/htzAgQspKZm1D32y0RxofrRQ==", - "dev": true, - "license": "MIT OR Apache-2.0" - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@poppinss/colors": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", - "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^4.1.5" - } - }, - "node_modules/@poppinss/dumper": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", - "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/colors": "^4.1.5", - "@sindresorhus/is": "^7.0.2", - "supports-color": "^10.0.0" - } - }, - "node_modules/@poppinss/exception": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", - "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sindresorhus/is": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", - "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@speed-highlight/core": { - "version": "1.2.17", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", - "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/blake3-wasm": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", - "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/error-stack-parser-es": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", - "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/miniflare": { - "version": "4.20260617.1", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260617.1.tgz", - "integrity": "sha512-Go3/gzStm99QHptsSgU+q1S+xDfLoRgwjJNY80kaTVi0ENhTyqKq+sc4xZiWBSbM7uUcJwmzm8+QFKtcYLJ9nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "0.8.1", - "sharp": "0.34.5", - "undici": "7.28.0", - "workerd": "1.20260617.1", - "ws": "8.21.0", - "youch": "4.1.0-beta.10" - }, - "bin": { - "miniflare": "bootstrap.js" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/unenv": { - "version": "2.0.0-rc.24", - "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", - "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "pathe": "^2.0.3" - } - }, - "node_modules/workerd": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260617.1.tgz", - "integrity": "sha512-Re5pl6pdowt3ZmWUzGlOuB7jbRIIPetgKalmo4cYmucQnVhpo7/3e4MfpekbhLi2EhZZz5EY9NWRu8zFzuEZew==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "bin": { - "workerd": "bin/workerd" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260617.1", - "@cloudflare/workerd-darwin-arm64": "1.20260617.1", - "@cloudflare/workerd-linux-64": "1.20260617.1", - "@cloudflare/workerd-linux-arm64": "1.20260617.1", - "@cloudflare/workerd-windows-64": "1.20260617.1" - } - }, - "node_modules/wrangler": { - "version": "4.103.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.103.0.tgz", - "integrity": "sha512-3Lv1P5t2xcSEkSTKtG+Lz+3JFryuU7YPLkaCUj7gNe+CJsjZJLtUwqsh1x595QBxkIbCE0GAvDx2DCJUU4+oqw==", - "dev": true, - "license": "MIT OR Apache-2.0", - "dependencies": { - "@cloudflare/kv-asset-handler": "0.5.0", - "@cloudflare/unenv-preset": "2.16.1", - "blake3-wasm": "2.1.5", - "esbuild": "0.28.1", - "miniflare": "4.20260617.1", - "path-to-regexp": "6.3.0", - "unenv": "2.0.0-rc.24", - "workerd": "1.20260617.1" - }, - "bin": { - "cf-wrangler": "bin/cf-wrangler.js", - "wrangler": "bin/wrangler.js", - "wrangler2": "bin/wrangler.js" - }, - "engines": { - "node": ">=22.0.0" - }, - "optionalDependencies": { - "fsevents": "2.3.3" - }, - "peerDependencies": { - "@cloudflare/workers-types": "^4.20260617.1" - }, - "peerDependenciesMeta": { - "@cloudflare/workers-types": { - "optional": true - } - } - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/youch": { - "version": "4.1.0-beta.10", - "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", - "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/colors": "^4.1.5", - "@poppinss/dumper": "^0.6.4", - "@speed-highlight/core": "^1.2.7", - "cookie": "^1.0.2", - "youch-core": "^0.3.3" - } - }, - "node_modules/youch-core": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", - "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/exception": "^1.2.2", - "error-stack-parser-es": "^1.0.5" - } - } - } -} diff --git a/workers/preview/package.json b/workers/preview/package.json deleted file mode 100644 index 8ade8d8c9..000000000 --- a/workers/preview/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "sure-preview-worker", - "version": "1.0.0", - "private": true, - "scripts": { - "deploy": "wrangler deploy", - "dev": "wrangler dev" - }, - "devDependencies": { - "@cloudflare/containers": "^0.3.3", - "@cloudflare/workers-types": "^4.20250124.0", - "typescript": "^5.0.0", - "wrangler": "^4.103.0" - } -} diff --git a/workers/preview/src/index.ts b/workers/preview/src/index.ts deleted file mode 100644 index e3c901479..000000000 --- a/workers/preview/src/index.ts +++ /dev/null @@ -1,497 +0,0 @@ -import { Container } from "@cloudflare/containers"; - -interface Env { - RAILS_CONTAINER: DurableObjectNamespace; -} - -interface DiagnosticPayload { - stage?: string; - detail?: string; -} - -interface DiagnosticRecord { - event?: string; - at?: string; - payload?: DiagnosticPayload; - state?: { status?: string; lastChange?: number }; - message?: string; -} - -interface PreviewProgress { - phase: "cold" | "warming" | "loading-demo-data" | "ready" | "failed"; - stage: string | null; - message: string; - detail: string; -} - -interface PreviewTimings { - containerStartedAt: string | null; - bootStartedAt: string | null; - railsStartedAt: string | null; - railsReadyAt: string | null; - demoDataStartedAt: string | null; - demoDataReadyAt: string | null; - demoDataReadyStage: string | null; - demoDataFailedAt: string | null; - previewReadyAt: string | null; - secondsToRailsReady: number | null; - secondsToDemoDataReady: number | null; - secondsFromRailsReadyToDemoDataReady: number | null; - secondsToPreviewReady: number | null; -} - -interface PreviewStatusPayload { - state: unknown; - containerRunning: boolean; - diagnostics: DiagnosticRecord | null; - diagnosticsHistory: DiagnosticRecord[]; - previewReady: boolean; - previewFailed: boolean; - timings: PreviewTimings; - progress: PreviewProgress; -} - -const DIAGNOSTICS_KEY = "preview-diagnostics"; -const DIAGNOSTICS_HISTORY_KEY = "preview-diagnostics-history"; -const DIAGNOSTICS_HISTORY_LIMIT = 50; -const PREVIEW_DIAGNOSTICS_NONCE = "${PREVIEW_DIAGNOSTICS_NONCE}"; -const READY_STAGES = new Set(["demo-data-ready", "demo-data-skip"]); -const FAILED_STAGES = new Set(["demo-data-failed", "failed"]); -const TIMING_ANCHOR_STAGES = new Set([ - "boot", - "rails-start", - "rails-up-ready", - "demo-data-check", - "demo-data-deferred", - "demo-data-load", - "demo-data-ready", - "demo-data-skip", - "demo-data-failed", - "failed", -]); -const WAITING_MESSAGES: Record = { - boot: "Waking preview…", - "redis-start": "Starting Redis…", - "redis-ready": "Redis is ready.", - "postgres-start": "Starting PostgreSQL…", - "postgres-ready": "PostgreSQL is ready.", - "postgres-already-running": "PostgreSQL is already running.", - "db-setup": "Setting up the preview database…", - "db-prepare": "Running database setup…", - "db-prepare-done": "Database setup finished.", - "demo-data-check": "Checking sample data…", - "demo-data-user-present": "Found the demo user. Verifying sample data…", - "demo-data-deferred": "Rails is up. Loading sample data…", - "demo-data-load": "Loading sample data…", - "demo-data-ready": "Sample data is ready.", - "demo-data-skip": "Sample data is already ready.", - "demo-data-failed": "Sample data failed to load.", - "rails-start": "Starting Rails…", - "rails-up-ready": "Rails is up. Finishing sample data…", - "rails-up-timeout": "Rails is taking longer than expected to start.", -}; - -export class RailsContainer extends Container { - defaultPort = 3000; - pingEndpoint = "localhost/up"; - entrypoint = ["/rails/bin/preview-entrypoint", "bundle", "exec", "puma", "-C", "config/puma.rb"]; - envVars = { - RAILS_ENV: "development", - RAILS_LOG_TO_STDOUT: "true", - RAILS_SERVE_STATIC_FILES: "true", - SECRET_KEY_BASE: "preview-secret-key-base-for-pr-${PR_NUMBER}", - APP_DOMAIN: "sure-preview-${PR_NUMBER}.sure-finances.workers.dev", - APP_URL: "https://sure-preview-${PR_NUMBER}.sure-finances.workers.dev", - RAILS_FORCE_SSL: "false", - RAILS_ASSUME_SSL: "false", - ACTIVE_STORAGE_SERVICE: "local", - DISABLE_BOOTSNAP: "1", - BINDING: "::", - DEMO_DATA_SEED: "${PR_NUMBER}", - PREVIEW_ORIGIN: "https://sure-preview-${PR_NUMBER}.sure-finances.workers.dev", - PREVIEW_DIAGNOSTICS_NONCE, - }; - sleepAfter = "30m"; - enableInternet = true; - - get runtimeContainer() { - return this.ctx.container!; - } - - async recordDiagnostic(payload: Record): Promise { - const diagnostic = { - ...payload, - state: await this.getState(), - } as DiagnosticRecord; - - await this.ctx.storage.put(DIAGNOSTICS_KEY, diagnostic); - - const history = - ((await this.ctx.storage.get(DIAGNOSTICS_HISTORY_KEY)) as DiagnosticRecord[] | undefined) ?? []; - - history.push(diagnostic); - await this.ctx.storage.put(DIAGNOSTICS_HISTORY_KEY, this.trimDiagnosticsHistory(history)); - } - - private isTimingAnchor(record: DiagnosticRecord): boolean { - return ( - record.event === "start" || - (record.event === "entrypoint" && - typeof record.payload?.stage === "string" && - TIMING_ANCHOR_STAGES.has(record.payload.stage)) - ); - } - - private trimDiagnosticsHistory(history: DiagnosticRecord[]): DiagnosticRecord[] { - if (history.length <= DIAGNOSTICS_HISTORY_LIMIT) return history; - - const anchors = history.filter((record) => this.isTimingAnchor(record)).slice(-DIAGNOSTICS_HISTORY_LIMIT); - const anchored = new Set(anchors); - const remainingSlots = Math.max(DIAGNOSTICS_HISTORY_LIMIT - anchors.length, 0); - const recentNonAnchors = - remainingSlots > 0 ? history.filter((record) => !anchored.has(record)).slice(-remainingSlots) : []; - const kept = new Set([...anchors, ...recentNonAnchors]); - - return history.filter((record) => kept.has(record)); - } - - private isAttemptStart(record: DiagnosticRecord): boolean { - return ( - record.event === "start" || - (record.event === "entrypoint" && typeof record.payload?.stage === "string" && record.payload.stage === "boot") - ); - } - - private diagnosticsForLatestAttempt(allDiagnostics: DiagnosticRecord[]): DiagnosticRecord[] { - for (let index = allDiagnostics.length - 1; index >= 0; index -= 1) { - if (this.isAttemptStart(allDiagnostics[index])) { - return allDiagnostics.slice(index); - } - } - - return allDiagnostics; - } - - private async getDiagnostics(): Promise<{ - state: unknown; - containerRunning: boolean; - diagnostics: DiagnosticRecord | null; - diagnosticsHistory: DiagnosticRecord[]; - }> { - return { - state: await this.getState(), - containerRunning: this.runtimeContainer.running, - diagnostics: ((await this.ctx.storage.get(DIAGNOSTICS_KEY)) as DiagnosticRecord | undefined) ?? null, - diagnosticsHistory: - ((await this.ctx.storage.get(DIAGNOSTICS_HISTORY_KEY)) as DiagnosticRecord[] | undefined) ?? [], - }; - } - - private async probeRailsUp(): Promise { - try { - const response = await this.containerFetch(new Request("https://container.internal/up"), this.defaultPort); - return response.ok; - } catch { - return false; - } - } - - private validTimestamp(value: string | undefined): string | null { - if (!value) return null; - - const timestamp = Date.parse(value); - return Number.isNaN(timestamp) ? null : value; - } - - private secondsBetween(startAt: string | null, endAt: string | null): number | null { - if (!startAt || !endAt) return null; - - const start = Date.parse(startAt); - const end = Date.parse(endAt); - if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null; - - return Math.round(((end - start) / 1000) * 100) / 100; - } - - private buildPreviewTimings(attemptDiagnostics: DiagnosticRecord[], previewReady: boolean): PreviewTimings { - const entrypointDiagnostics = attemptDiagnostics.filter( - (item) => item.event === "entrypoint" && typeof item.payload?.stage === "string" - ); - const firstEventAt = (event: string) => - this.validTimestamp(attemptDiagnostics.find((item) => item.event === event)?.at); - const firstStageAt = (...stages: string[]) => - this.validTimestamp(entrypointDiagnostics.find((item) => stages.includes(item.payload?.stage ?? ""))?.at); - - const containerStartedAt = firstEventAt("start"); - const bootStartedAt = firstStageAt("boot") ?? containerStartedAt; - const railsStartedAt = firstStageAt("rails-start"); - const railsReadyAt = firstStageAt("rails-up-ready"); - const demoDataStartedAt = - firstStageAt("demo-data-load") ?? firstStageAt("demo-data-deferred") ?? firstStageAt("demo-data-check"); - const demoDataReady = entrypointDiagnostics.find((item) => READY_STAGES.has(item.payload?.stage ?? "")); - const demoDataReadyAt = this.validTimestamp(demoDataReady?.at); - const demoDataReadyStage = demoDataReady?.payload?.stage ?? null; - const demoDataFailedAt = firstStageAt("demo-data-failed"); - const previewReadyAt = previewReady ? (demoDataReadyAt ?? railsReadyAt) : null; - - return { - containerStartedAt, - bootStartedAt, - railsStartedAt, - railsReadyAt, - demoDataStartedAt, - demoDataReadyAt, - demoDataReadyStage, - demoDataFailedAt, - previewReadyAt, - secondsToRailsReady: this.secondsBetween(bootStartedAt, railsReadyAt), - secondsToDemoDataReady: this.secondsBetween(demoDataStartedAt, demoDataReadyAt), - secondsFromRailsReadyToDemoDataReady: this.secondsBetween(railsReadyAt, demoDataReadyAt), - secondsToPreviewReady: this.secondsBetween(bootStartedAt, previewReadyAt), - }; - } - - private async buildPreviewStatus(base: { - state: unknown; - containerRunning: boolean; - diagnostics: DiagnosticRecord | null; - diagnosticsHistory: DiagnosticRecord[]; - }, options?: { probe?: boolean }): Promise { - const allDiagnostics = [...base.diagnosticsHistory, ...(base.diagnostics ? [base.diagnostics] : [])]; - const attemptDiagnostics = this.diagnosticsForLatestAttempt(allDiagnostics); - const entrypointDiagnostics = attemptDiagnostics.filter( - (item) => item.event === "entrypoint" && typeof item.payload?.stage === "string" - ); - const latestEntrypoint = entrypointDiagnostics.at(-1) ?? null; - const latestStage = latestEntrypoint?.payload?.stage ?? null; - const latestDetail = latestEntrypoint?.payload?.detail ?? base.diagnostics?.message ?? ""; - const sampleDataReady = entrypointDiagnostics.some((item) => READY_STAGES.has(item.payload?.stage ?? "")); - const liveProbeReady = options?.probe ? await this.probeRailsUp() : false; - const railsResponding = - liveProbeReady || - (typeof base.state === "object" && base.state !== null && "status" in base.state - ? (base.state as { status?: string }).status === "healthy" - : false) || - entrypointDiagnostics.some((item) => item.payload?.stage === "rails-up-ready"); - const previewReady = sampleDataReady && railsResponding; - const previewFailed = - entrypointDiagnostics.some((item) => FAILED_STAGES.has(item.payload?.stage ?? "")) || - base.diagnostics?.event === "error"; - const timings = this.buildPreviewTimings(attemptDiagnostics, previewReady); - - let phase: PreviewProgress["phase"] = "cold"; - if (previewFailed) { - phase = "failed"; - } else if (previewReady) { - phase = "ready"; - } else if ( - latestStage === "demo-data-load" || - latestStage === "demo-data-deferred" || - latestStage === "rails-up-ready" || - latestStage === "demo-data-check" || - latestStage === "demo-data-user-present" - ) { - phase = "loading-demo-data"; - } else if (base.containerRunning || latestEntrypoint) { - phase = "warming"; - } - - const message = sampleDataReady && !previewReady - ? "Finishing preview startup…" - : (latestStage ? WAITING_MESSAGES[latestStage] : undefined) ?? - (previewFailed - ? "Preview startup hit an error." - : previewReady - ? "Preview is ready." - : base.containerRunning - ? "Warming preview…" - : "Starting preview…"); - - return { - ...base, - previewReady, - previewFailed, - timings, - progress: { - phase, - stage: latestStage, - message, - detail: latestDetail, - }, - }; - } - - private wantsHtml(request: Request): boolean { - if (request.method !== "GET") return false; - const accept = request.headers.get("accept") ?? ""; - const secFetchDest = request.headers.get("sec-fetch-dest") ?? ""; - return accept.includes("text/html") || secFetchDest === "document"; - } - - private renderWaitPage(request: Request, status: PreviewStatusPayload, errorMessage?: string): Response { - const targetPath = new URL(request.url).pathname + new URL(request.url).search; - const escapedTargetPath = JSON.stringify(targetPath); - const escapedMessage = JSON.stringify(status.progress.message); - const escapedDetail = JSON.stringify( - status.progress.detail || errorMessage || "This preview is waking up and loading sample data." - ); - - const html = ` - - - - - Waking preview… - - - -
-
- -

-

-

Please wait — this preview is cold-starting and will redirect automatically when the sample data is ready.

-

-
-
- - -`; - - return new Response(html, { - status: status.previewFailed ? 503 : 202, - headers: { - "content-type": "text/html; charset=utf-8", - "cache-control": "no-store, max-age=0", - "retry-after": "3", - }, - }); - } - - override async fetch(request: Request): Promise { - const url = new URL(request.url); - - if (url.pathname === "/_container_status") { - return Response.json(await this.buildPreviewStatus(await this.getDiagnostics(), { probe: true })); - } - - if (url.pathname === "/_container_event" && request.method === "POST") { - if (request.headers.get("x-preview-diagnostics-nonce") !== PREVIEW_DIAGNOSTICS_NONCE) { - return new Response("not found", { status: 404 }); - } - - const payload = await request.json(); - await this.recordDiagnostic({ - event: "entrypoint", - at: new Date().toISOString(), - payload, - }); - return new Response("ok"); - } - - try { - return await this.containerFetch(request, this.defaultPort); - } catch (error) { - await this.recordDiagnostic({ - event: "container-fetch-error", - at: new Date().toISOString(), - message: error instanceof Error ? error.message : String(error), - }); - - const status = await this.buildPreviewStatus(await this.getDiagnostics()); - if (this.wantsHtml(request) && !status.previewReady) { - return this.renderWaitPage( - request, - status, - error instanceof Error ? error.message : String(error) - ); - } - - return new Response( - `Failed to serve preview container: ${error instanceof Error ? error.message : String(error)}`, - { status: 500 } - ); - } - } - - override async onStart(): Promise { - await this.recordDiagnostic({ - event: "start", - at: new Date().toISOString(), - }); - } - - override async onStop(stopParams: { exitCode?: number; reason?: string }): Promise { - await this.recordDiagnostic({ - event: "stop", - at: new Date().toISOString(), - exitCode: stopParams.exitCode, - reason: stopParams.reason, - }); - } - - override async onError(error: unknown): Promise { - console.error("Rails container error:", error); - await this.recordDiagnostic({ - event: "error", - at: new Date().toISOString(), - message: error instanceof Error ? error.message : String(error), - }); - throw error; - } -} - -export default { - async fetch( - request: Request, - env: Env, - _ctx: ExecutionContext - ): Promise { - const id = env.RAILS_CONTAINER.idFromName("preview"); - const container = env.RAILS_CONTAINER.get(id); - - return container.fetch(request); - }, -}; diff --git a/workers/preview/tsconfig.json b/workers/preview/tsconfig.json deleted file mode 100644 index 61f2674cb..000000000 --- a/workers/preview/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "lib": ["ES2022"], - "types": ["@cloudflare/workers-types"], - "strict": true, - "noEmit": true, - "skipLibCheck": true - }, - "include": ["src/**/*.ts"] -} diff --git a/workers/preview/wrangler.toml b/workers/preview/wrangler.toml deleted file mode 100644 index 9845919d2..000000000 --- a/workers/preview/wrangler.toml +++ /dev/null @@ -1,48 +0,0 @@ -# Cloudflare Containers configuration for PR preview deployments -# This file is used as a template - the GitHub workflow substitutes PR_NUMBER - -name = "sure-preview-${PR_NUMBER}" -main = "./src/index.ts" -compatibility_date = "2026-05-11" - -# Enable workers.dev subdomain for preview access -workers_dev = true - -# Enable container logs -[observability] -enabled = true - -# Container configuration - uses preview Dockerfile with embedded PostgreSQL -# Note: path is relative to this config file location -[[containers]] -class_name = "RailsContainer" -image = "../../Dockerfile.preview" -# standard-1 (1/2 vCPU, 4 GiB RAM, 8 GB disk), not basic (1/4 vCPU, 1 GiB, -# 4 GB): this single container runs postgres + redis + puma AND generates the -# full demo dataset (Demo::Generator: ~12 years of transactions). That -# generation peaks just over 1 GiB, so basic OOM-kills it (exit 137) mid-boot -# and the preview never reaches demo-data-ready. Verified on a real Cloudflare -# standard-1 deploy: rails ready ~46s, demo data ~149s, previewReady ~195s, -# peak well under 4 GiB, no OOM. -instance_type = "standard-1" -max_instances = 1 - -# Durable Object binding for the container -[[durable_objects.bindings]] -name = "RAILS_CONTAINER" -class_name = "RailsContainer" - -# Required migration for Durable Objects with SQLite -[[migrations]] -tag = "v1" -new_sqlite_classes = ["RailsContainer"] - -# Environment variables passed to the Rails container -[vars] -RAILS_ENV = "development" -RAILS_LOG_TO_STDOUT = "true" -RAILS_SERVE_STATIC_FILES = "true" - -# Note: SECRET_KEY_BASE and DATABASE_URL are auto-generated by the entrypoint -# for development previews. For custom configuration, set via: -# wrangler secret put SECRET_KEY_BASE