ci: deploy isolated GPL staging stack

This commit is contained in:
Istvan Matejcsok 2026-08-07 11:52:25 +02:00
commit f90cf7d2da
6 changed files with 224 additions and 1 deletions

106
.github/workflows/deploy-staging.yml vendored Normal file
View file

@ -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'

View file

@ -5,3 +5,8 @@
/* /*
Cross-Origin-Opener-Policy: same-origin Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp 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

View file

@ -86,7 +86,10 @@ function main() {
const standalone = join(repoRoot, "web/standalone"); const standalone = join(repoRoot, "web/standalone");
const dist = join(standalone, "dist"); const dist = join(standalone, "dist");
const publicWasm = join(standalone, "public/wasm"); 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 = { const env = {
...process.env, ...process.env,
@ -152,6 +155,7 @@ function main() {
// Belt-and-suspenders: never ship local wasm even if a copy slipped through. // 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"), { 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 // Same Pages headers as the demo: COOP/COEP for WASM threads (the API's CORS
// satisfies COEP for credentialed cross-origin fetches) + SPA fallback. // satisfies COEP for credentialed cross-origin fetches) + SPA fallback.

View file

@ -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"
}
}

85
workers/cdn/src/index.ts Normal file
View file

@ -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<Response> {
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,
});
},
};

View file

@ -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" }
]
}