build-preview-cloudflare.yml 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. name: Build PR preview
  2. # Every push to a pull request publishes that pull request's preview to
  3. # Cloudflare Pages under its own branch alias, behind Cloudflare Access. The
  4. # upload carries build products only: the workflow never grants the deployment
  5. # platform access to this repository's sources.
  6. on:
  7. pull_request:
  8. types: [opened, synchronize, reopened]
  9. # Within one pull request the newest build wins. Across pull requests there is
  10. # nothing to serialize: each uploads to its own branch alias, so two deployments
  11. # never contend for the same URL.
  12. concurrency:
  13. group: build-preview-cloudflare-${{ github.event.pull_request.number }}
  14. cancel-in-progress: true
  15. permissions:
  16. contents: read
  17. pull-requests: write
  18. env:
  19. PRIMARY_NODE_VERSION: '24'
  20. # Cloudflare Pages project receiving the upload. Its preview deployments are
  21. # the surface the Access application protects; the project's production branch
  22. # is deliberately a name no deployment uses, so no unprotected URL exists.
  23. CF_PROJECT: dsh-build-preview
  24. # CI runs must never report to the production telemetry endpoint baked into
  25. # apps/cli/cordis.yml (AppCLIEntry disables the row when set).
  26. DSH_TELEMETRY_DISABLED: '1'
  27. jobs:
  28. preview:
  29. runs-on: dsh-ubuntu-24-04-16core
  30. name: cloudflare pages preview
  31. steps:
  32. - uses: actions/checkout@v6
  33. with:
  34. persist-credentials: false
  35. - uses: pnpm/action-setup@v4
  36. with:
  37. dest: ${{ runner.temp }}/setup-pnpm
  38. - uses: actions/setup-node@v6
  39. with:
  40. node-version: ${{ env.PRIMARY_NODE_VERSION }}
  41. - name: Configure pnpm store path
  42. id: pnpm-store
  43. run: |
  44. store_root="$HOME/.local/share/pnpm/store"
  45. echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
  46. store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
  47. echo "path=$store_path" >> "$GITHUB_OUTPUT"
  48. # Read-only: the preview lane consumes the default-branch cache without
  49. # putting cache upload on its own path.
  50. - uses: actions/cache/restore@v4
  51. with:
  52. path: ${{ steps.pnpm-store.outputs.path }}
  53. key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
  54. restore-keys: |
  55. ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
  56. - name: Install (immutable)
  57. run: pnpm install --frozen-lockfile
  58. # apps/web consumes workspace packages as built lib products, and
  59. # build:preview packs the image through the packer's installed bin
  60. # (lib/bin.js), so neither half exists before the full build runs.
  61. - name: Build workspace
  62. run: pnpm run build
  63. - name: Build the preview page and pack the VFS image
  64. env:
  65. DSH_CLIENT_TITLE: DSH preview pr-${{ github.event.pull_request.number }}
  66. run: pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview
  67. # Sourcemaps carry complete sources and stay off the deployment platform.
  68. # index.html is the served page, which cannot boot without a host
  69. # injecting window.__DSH_BOOT__; replacing it with the worker page makes
  70. # the deployment root the usable entry instead of a page that never boots.
  71. - name: Shape the upload
  72. run: |
  73. find apps/web/dist -name '*.map' -delete
  74. cp apps/web/dist/preview.html apps/web/dist/index.html
  75. - name: Upload to Cloudflare Pages
  76. env:
  77. CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
  78. CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
  79. run: |
  80. npx --yes wrangler@4 pages deploy apps/web/dist \
  81. --project-name "$CF_PROJECT" \
  82. --branch "pr-${{ github.event.pull_request.number }}" \
  83. --commit-dirty=true
  84. # The image is what a worker boot fails on first and least visibly, so the
  85. # run only passes once the protected URL serves it as gzip bytes. Three
  86. # facts are asserted, each with its own failure meaning:
  87. # 200 Access admitted the request; a 302 means the
  88. # Access policy is missing its Service Auth rule
  89. # for this token
  90. # no content-encoding the platform did not claim transport
  91. # compression, which would make the browser
  92. # decode the body and leave the worker's
  93. # DecompressionStream inflating a plain tar
  94. # gzip magic 1f 8b the bytes really are the gzip member the
  95. # packer wrote
  96. # Accept-Encoding is sent because a browser sends it; the assertion is
  97. # about what the platform does with a body that is already compressed.
  98. - name: Verify the protected deployment serves the image
  99. env:
  100. CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }}
  101. CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }}
  102. run: |
  103. url="https://pr-${{ github.event.pull_request.number }}.${CF_PROJECT}.pages.dev"
  104. image="$url/preview/vfs-image.tar.gz"
  105. code=000
  106. for attempt in 1 2 3 4 5; do
  107. code=$(curl -sS -o image.bin -D headers.txt -w '%{http_code}' \
  108. -H 'Accept-Encoding: gzip' \
  109. -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
  110. -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \
  111. "$image" || echo 000)
  112. echo "attempt $attempt: HTTP $code"
  113. if [ "$code" = "200" ]; then break; fi
  114. sleep 10
  115. done
  116. if [ "$code" != "200" ]; then
  117. echo "the protected image URL answered $code, not 200"
  118. head -20 headers.txt
  119. exit 1
  120. fi
  121. if grep -qi '^content-encoding:' headers.txt; then
  122. echo "the platform declared transport compression on an already-compressed image:"
  123. grep -i '^content-encoding:' headers.txt
  124. exit 1
  125. fi
  126. magic=$(head -c 2 image.bin | od -An -tx1 | tr -d ' \n')
  127. if [ "$magic" != "1f8b" ]; then
  128. echo "image does not start with the gzip magic number: $magic"
  129. exit 1
  130. fi
  131. echo "image served as $(wc -c < image.bin) gzip bytes"
  132. # The alias URL follows from the pull request number, so it is stable
  133. # across redeploys and worth stating once. The marker makes the comment
  134. # idempotent: a pull request opened before this workflow existed never
  135. # sees an `opened` event, and every later push must not restate the URL.
  136. - name: Comment the preview URL
  137. env:
  138. GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  139. PR: ${{ github.event.pull_request.number }}
  140. run: |
  141. marker='<!-- dsh-preview-url -->'
  142. existing=$(gh pr view "$PR" --json comments \
  143. --jq "[.comments[] | select(.body | contains(\"$marker\")) | .url] | first // empty")
  144. if [ -n "$existing" ]; then
  145. echo "preview URL already commented: $existing"
  146. exit 0
  147. fi
  148. # The marker sits on its own line: markdown renders no link on a
  149. # line that opens with a raw HTML comment.
  150. printf '%s\n\n%s\n' \
  151. "$marker" \
  152. "[Preview for #$PR](https://pr-$PR.${CF_PROJECT}.pages.dev) (requires Cloudflare Access sign-in)" \
  153. | gh pr comment "$PR" --body-file -