ci: deploy isolated GPL staging stack
This commit is contained in:
parent
4d75d44a78
commit
f90cf7d2da
6 changed files with 224 additions and 1 deletions
106
.github/workflows/deploy-staging.yml
vendored
Normal file
106
.github/workflows/deploy-staging.yml
vendored
Normal 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'
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
11
web/standalone/wrangler.staging.jsonc
Normal file
11
web/standalone/wrangler.staging.jsonc
Normal 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
85
workers/cdn/src/index.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
12
workers/cdn/wrangler.staging.jsonc
Normal file
12
workers/cdn/wrangler.staging.jsonc
Normal 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" }
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue