diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml new file mode 100644 index 0000000..babdf0d --- /dev/null +++ b/.github/workflows/deploy-staging.yml @@ -0,0 +1,106 @@ +name: deploy staging (GPL stack) + +on: + push: + branches: [staging] + workflow_dispatch: + +concurrency: + group: deploy-staging-gpl + cancel-in-progress: true + +permissions: + contents: read + +env: + NODE_VERSION: "22" + CDN_ORIGIN: https://pcbjam-cdn-staging.pcbjam-staging.workers.dev + API_ORIGIN: https://pcbjam-server-staging.pcbjam-staging.workers.dev + APP_ORIGIN: https://pcbjam-app-staging.pcbjam-staging.workers.dev + EDITOR_ORIGIN: https://pcbjam-editor-staging.pcbjam-staging.workers.dev + BUCKET: pcbjam-cdn-staging + WRANGLER_CMD: npx --yes wrangler@4.99.0 + +jobs: + build: + uses: ./.github/workflows/wasm-build.yml + with: + opt_level: "-O1" + build_3d_viewer: "ON" + run_tests: true + upload_output: true + + publish-and-deploy: + needs: build + runs-on: ubicloud-standard-8 + timeout-minutes: 90 + environment: staging + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + steps: + - uses: actions/checkout@v4 + with: + submodules: false + + - name: Validate staging deployment credentials + run: | + test -n "$CLOUDFLARE_API_TOKEN" || { echo "staging CLOUDFLARE_API_TOKEN is missing"; exit 1; } + test -n "$CLOUDFLARE_ACCOUNT_ID" || { echo "staging CLOUDFLARE_ACCOUNT_ID variable is missing"; exit 1; } + test "$CLOUDFLARE_ACCOUNT_ID" = "5be62ba000cabfaad4d11a6ef28f8395" || { echo "refusing to deploy outside the staging Cloudflare account"; exit 1; } + + - name: Init pcbjam-shared submodule + run: git submodule update --init --depth 1 web/pcbjam-shared + + - uses: pnpm/action-setup@v4 + with: + version: 10.33.0 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + + - name: Install standalone workspace + run: pnpm --dir web install --frozen-lockfile + + - name: Download WASM output + uses: actions/download-artifact@v4 + with: + name: wasm-output + path: output + + - name: Publish WASM to staging R2 + env: + STAGING_TAG: staging-${{ github.sha }} + run: > + node scripts/deploy/publish-wasm.mjs --tag "$STAGING_TAG" + --src output --driver r2 --bucket "$BUCKET" --remote + --compress br --quality 11 + + - name: Deploy staging CDN Worker + run: npx --yes wrangler@4.99.0 deploy --config workers/cdn/wrangler.staging.jsonc + + - name: Build staging editor + env: + STAGING_TAG: staging-${{ github.sha }} + run: > + node scripts/deploy/build-editor.mjs --tag "$STAGING_TAG" + --cdn "$CDN_ORIGIN" --api-base "$API_ORIGIN" + --app-base "$APP_ORIGIN" --errors-env staging + + - name: Deploy staging editor Worker + run: npx --yes wrangler@4.99.0 deploy --config web/standalone/wrangler.staging.jsonc + + - name: Smoke-check staging GPL surfaces + env: + STAGING_TAG: staging-${{ github.sha }} + run: | + curl --fail --retry 12 --retry-delay 5 "$CDN_ORIGIN/wasm/manifest-$STAGING_TAG.json" >/dev/null + curl --fail --retry 12 --retry-delay 5 "$EDITOR_ORIGIN/" >/dev/null + curl --fail --retry 12 --retry-delay 5 -I "$CDN_ORIGIN/wasm/manifest-$STAGING_TAG.json" | grep -qi '^access-control-allow-origin: \*' + headers=$(curl --fail --retry 12 --retry-delay 5 -I "$EDITOR_ORIGIN/") + echo "$headers" | grep -qi '^cross-origin-opener-policy: same-origin' + echo "$headers" | grep -qi '^cross-origin-embedder-policy: require-corp' + echo "$headers" | grep -qi '^x-robots-tag:.*noindex' diff --git a/deploy/demo/_headers b/deploy/demo/_headers index cd16a51..60ba705 100644 --- a/deploy/demo/_headers +++ b/deploy/demo/_headers @@ -5,3 +5,8 @@ /* Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp + +# Never index the free staging hostname. This absolute rule is inert on the +# production Pages/custom-domain deployments that reuse this file. +https://pcbjam-editor-staging.pcbjam-staging.workers.dev/* + X-Robots-Tag: noindex, nofollow diff --git a/scripts/deploy/build-editor.mjs b/scripts/deploy/build-editor.mjs index 39227e9..9e1db19 100644 --- a/scripts/deploy/build-editor.mjs +++ b/scripts/deploy/build-editor.mjs @@ -86,7 +86,10 @@ function main() { const standalone = join(repoRoot, "web/standalone"); const dist = join(standalone, "dist"); const publicWasm = join(standalone, "public/wasm"); - const stash = join(standalone, "public/.wasm.editor-stashed"); + // Keep the temporary symlink OUTSIDE public/. Vite copies every public entry, + // including dot-directories; stashing it under public previously smuggled the + // full local WASM tree into dist under `.wasm.editor-stashed/`. + const stash = join(standalone, ".wasm.editor-stashed"); const env = { ...process.env, @@ -152,6 +155,7 @@ function main() { // Belt-and-suspenders: never ship local wasm even if a copy slipped through. rmSync(join(dist, "wasm"), { recursive: true, force: true }); + rmSync(join(dist, ".wasm.editor-stashed"), { recursive: true, force: true }); // Same Pages headers as the demo: COOP/COEP for WASM threads (the API's CORS // satisfies COEP for credentialed cross-origin fetches) + SPA fallback. diff --git a/web/standalone/wrangler.staging.jsonc b/web/standalone/wrangler.staging.jsonc new file mode 100644 index 0000000..c1b4bdf --- /dev/null +++ b/web/standalone/wrangler.staging.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "pcbjam-editor-staging", + "compatibility_date": "2026-07-27", + "workers_dev": true, + "preview_urls": false, + "assets": { + "directory": "./dist", + "not_found_handling": "single-page-application" + } +} diff --git a/workers/cdn/src/index.ts b/workers/cdn/src/index.ts new file mode 100644 index 0000000..71e7ef2 --- /dev/null +++ b/workers/cdn/src/index.ts @@ -0,0 +1,85 @@ +interface Env { + BUCKET: R2Bucket; +} + +const baseHeaders = (): Headers => { + const headers = new Headers(); + headers.set("Access-Control-Allow-Origin", "*"); + headers.set("Cross-Origin-Resource-Policy", "cross-origin"); + headers.set("X-Content-Type-Options", "nosniff"); + headers.set("X-Robots-Tag", "noindex, nofollow"); + return headers; +}; + +export default { + async fetch(request: Request, env: Env): Promise { + if (request.method === "OPTIONS") { + const headers = baseHeaders(); + headers.set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS"); + headers.set("Access-Control-Allow-Headers", "Range, If-None-Match"); + headers.set("Access-Control-Max-Age", "86400"); + return new Response(null, { status: 204, headers }); + } + if (request.method !== "GET" && request.method !== "HEAD") { + return new Response("Method Not Allowed", { + status: 405, + headers: baseHeaders(), + }); + } + + const url = new URL(request.url); + let key: string; + try { + key = decodeURIComponent(url.pathname.replace(/^\/+/, "")); + } catch { + return new Response("Bad Request", { status: 400, headers: baseHeaders() }); + } + if (!key) { + return Response.json( + { ok: true, service: "pcbjam-cdn-staging" }, + { headers: baseHeaders() }, + ); + } + + const object = await env.BUCKET.get(key, { + range: request.headers, + }); + if (!object) { + return new Response("Not Found", { status: 404, headers: baseHeaders() }); + } + + const headers = baseHeaders(); + object.writeHttpMetadata(headers); + headers.set("ETag", object.httpEtag); + headers.set("Accept-Ranges", "bytes"); + + if (request.headers.get("If-None-Match") === object.httpEtag) { + return new Response(null, { status: 304, headers }); + } + + let status = 200; + if (object.range) { + const suffix = "suffix" in object.range ? object.range.suffix : undefined; + const length = suffix + ? Math.min(suffix, object.size) + : "length" in object.range && object.range.length !== undefined + ? object.range.length + : object.size - ("offset" in object.range ? (object.range.offset ?? 0) : 0); + const offset = suffix + ? object.size - length + : "offset" in object.range + ? (object.range.offset ?? 0) + : 0; + headers.set("Content-Range", `bytes ${offset}-${offset + length - 1}/${object.size}`); + headers.set("Content-Length", String(length)); + status = 206; + } else { + headers.set("Content-Length", String(object.size)); + } + + return new Response(request.method === "HEAD" ? null : object.body, { + status, + headers, + }); + }, +}; diff --git a/workers/cdn/wrangler.staging.jsonc b/workers/cdn/wrangler.staging.jsonc new file mode 100644 index 0000000..d202aff --- /dev/null +++ b/workers/cdn/wrangler.staging.jsonc @@ -0,0 +1,12 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "pcbjam-cdn-staging", + "main": "src/index.ts", + "compatibility_date": "2026-07-27", + "workers_dev": true, + "preview_urls": false, + "observability": { "enabled": true }, + "r2_buckets": [ + { "binding": "BUCKET", "bucket_name": "pcbjam-cdn-staging" } + ] +}