|
|
@@ -0,0 +1,166 @@
|
|
|
+name: Build PR preview
|
|
|
+
|
|
|
+# Every push to a pull request publishes that pull request's preview to
|
|
|
+# Cloudflare Pages under its own branch alias, behind Cloudflare Access. The
|
|
|
+# upload carries build products only: the workflow never grants the deployment
|
|
|
+# platform access to this repository's sources.
|
|
|
+
|
|
|
+on:
|
|
|
+ pull_request:
|
|
|
+ types: [opened, synchronize, reopened]
|
|
|
+
|
|
|
+# Within one pull request the newest build wins. Across pull requests there is
|
|
|
+# nothing to serialize: each uploads to its own branch alias, so two deployments
|
|
|
+# never contend for the same URL.
|
|
|
+concurrency:
|
|
|
+ group: build-preview-cloudflare-${{ github.event.pull_request.number }}
|
|
|
+ cancel-in-progress: true
|
|
|
+
|
|
|
+permissions:
|
|
|
+ contents: read
|
|
|
+ pull-requests: write
|
|
|
+
|
|
|
+env:
|
|
|
+ PRIMARY_NODE_VERSION: '24'
|
|
|
+ # Cloudflare Pages project receiving the upload. Its preview deployments are
|
|
|
+ # the surface the Access application protects; the project's production branch
|
|
|
+ # is deliberately a name no deployment uses, so no unprotected URL exists.
|
|
|
+ CF_PROJECT: dsh-build-preview
|
|
|
+ # CI runs must never report to the production telemetry endpoint baked into
|
|
|
+ # apps/cli/cordis.yml (AppCLIEntry disables the row when set).
|
|
|
+ DSH_TELEMETRY_DISABLED: '1'
|
|
|
+
|
|
|
+jobs:
|
|
|
+ preview:
|
|
|
+ runs-on: dsh-ubuntu-24-04-16core
|
|
|
+ name: cloudflare pages preview
|
|
|
+ steps:
|
|
|
+ - uses: actions/checkout@v6
|
|
|
+ with:
|
|
|
+ persist-credentials: false
|
|
|
+
|
|
|
+ - uses: pnpm/action-setup@v4
|
|
|
+ with:
|
|
|
+ dest: ${{ runner.temp }}/setup-pnpm
|
|
|
+
|
|
|
+ - uses: actions/setup-node@v6
|
|
|
+ with:
|
|
|
+ node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
|
|
+
|
|
|
+ - name: Configure pnpm store path
|
|
|
+ id: pnpm-store
|
|
|
+ run: |
|
|
|
+ store_root="$HOME/.local/share/pnpm/store"
|
|
|
+ echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
|
|
|
+ store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
|
|
|
+ echo "path=$store_path" >> "$GITHUB_OUTPUT"
|
|
|
+
|
|
|
+ # Read-only: the preview lane consumes the default-branch cache without
|
|
|
+ # putting cache upload on its own path.
|
|
|
+ - uses: actions/cache/restore@v4
|
|
|
+ with:
|
|
|
+ path: ${{ steps.pnpm-store.outputs.path }}
|
|
|
+ key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
|
|
+ restore-keys: |
|
|
|
+ ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
|
|
|
+
|
|
|
+ - name: Install (immutable)
|
|
|
+ run: pnpm install --frozen-lockfile
|
|
|
+
|
|
|
+ # apps/web consumes workspace packages as built lib products, and
|
|
|
+ # build:preview packs the image through the packer's installed bin
|
|
|
+ # (lib/bin.js), so neither half exists before the full build runs.
|
|
|
+ - name: Build workspace
|
|
|
+ run: pnpm run build
|
|
|
+
|
|
|
+ - name: Build the preview page and pack the VFS image
|
|
|
+ env:
|
|
|
+ DSH_CLIENT_TITLE: DSH preview pr-${{ github.event.pull_request.number }}
|
|
|
+ run: pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview
|
|
|
+
|
|
|
+ # Sourcemaps carry complete sources and stay off the deployment platform.
|
|
|
+ # index.html is the served page, which cannot boot without a host
|
|
|
+ # injecting window.__DSH_BOOT__; replacing it with the worker page makes
|
|
|
+ # the deployment root the usable entry instead of a page that never boots.
|
|
|
+ - name: Shape the upload
|
|
|
+ run: |
|
|
|
+ find apps/web/dist -name '*.map' -delete
|
|
|
+ cp apps/web/dist/preview.html apps/web/dist/index.html
|
|
|
+
|
|
|
+ - name: Upload to Cloudflare Pages
|
|
|
+ env:
|
|
|
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
|
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
|
+ run: |
|
|
|
+ npx --yes wrangler@4 pages deploy apps/web/dist \
|
|
|
+ --project-name "$CF_PROJECT" \
|
|
|
+ --branch "pr-${{ github.event.pull_request.number }}" \
|
|
|
+ --commit-dirty=true
|
|
|
+
|
|
|
+ # The image is what a worker boot fails on first and least visibly, so the
|
|
|
+ # run only passes once the protected URL serves it as gzip bytes. Three
|
|
|
+ # facts are asserted, each with its own failure meaning:
|
|
|
+ # 200 Access admitted the request; a 302 means the
|
|
|
+ # Access policy is missing its Service Auth rule
|
|
|
+ # for this token
|
|
|
+ # no content-encoding the platform did not claim transport
|
|
|
+ # compression, which would make the browser
|
|
|
+ # decode the body and leave the worker's
|
|
|
+ # DecompressionStream inflating a plain tar
|
|
|
+ # gzip magic 1f 8b the bytes really are the gzip member the
|
|
|
+ # packer wrote
|
|
|
+ # Accept-Encoding is sent because a browser sends it; the assertion is
|
|
|
+ # about what the platform does with a body that is already compressed.
|
|
|
+ - name: Verify the protected deployment serves the image
|
|
|
+ env:
|
|
|
+ CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }}
|
|
|
+ CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }}
|
|
|
+ run: |
|
|
|
+ url="https://pr-${{ github.event.pull_request.number }}.${CF_PROJECT}.pages.dev"
|
|
|
+ image="$url/preview/vfs-image.tar.gz"
|
|
|
+ code=000
|
|
|
+ for attempt in 1 2 3 4 5; do
|
|
|
+ code=$(curl -sS -o image.bin -D headers.txt -w '%{http_code}' \
|
|
|
+ -H 'Accept-Encoding: gzip' \
|
|
|
+ -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
|
|
|
+ -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \
|
|
|
+ "$image" || echo 000)
|
|
|
+ echo "attempt $attempt: HTTP $code"
|
|
|
+ if [ "$code" = "200" ]; then break; fi
|
|
|
+ sleep 10
|
|
|
+ done
|
|
|
+ if [ "$code" != "200" ]; then
|
|
|
+ echo "the protected image URL answered $code, not 200"
|
|
|
+ head -20 headers.txt
|
|
|
+ exit 1
|
|
|
+ fi
|
|
|
+ if grep -qi '^content-encoding:' headers.txt; then
|
|
|
+ echo "the platform declared transport compression on an already-compressed image:"
|
|
|
+ grep -i '^content-encoding:' headers.txt
|
|
|
+ exit 1
|
|
|
+ fi
|
|
|
+ magic=$(head -c 2 image.bin | od -An -tx1 | tr -d ' \n')
|
|
|
+ if [ "$magic" != "1f8b" ]; then
|
|
|
+ echo "image does not start with the gzip magic number: $magic"
|
|
|
+ exit 1
|
|
|
+ fi
|
|
|
+ echo "image served as $(wc -c < image.bin) gzip bytes"
|
|
|
+
|
|
|
+ # The alias URL follows from the pull request number, so it is stable
|
|
|
+ # across redeploys and worth stating once. The marker makes the comment
|
|
|
+ # idempotent: a pull request opened before this workflow existed never
|
|
|
+ # sees an `opened` event, and every later push must not restate the URL.
|
|
|
+ - name: Comment the preview URL
|
|
|
+ env:
|
|
|
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
|
+ PR: ${{ github.event.pull_request.number }}
|
|
|
+ run: |
|
|
|
+ marker='<!-- dsh-preview-url -->'
|
|
|
+ existing=$(gh pr view "$PR" --json comments \
|
|
|
+ --jq "[.comments[] | select(.body | contains(\"$marker\")) | .url] | first // empty")
|
|
|
+ if [ -n "$existing" ]; then
|
|
|
+ echo "preview URL already commented: $existing"
|
|
|
+ exit 0
|
|
|
+ fi
|
|
|
+ gh pr comment "$PR" --body \
|
|
|
+ "$marker \n [Preview for #$PR](https://pr-$PR.${CF_PROJECT}.pages.dev) (requires Cloudflare Access sign-in)"
|