From be41af309cf7b0ed8ad80ab6adf608ef9fecba3a Mon Sep 17 00:00:00 2001 From: Radon / ELRdn Date: Thu, 29 Oct 2026 23:11:09 +0900 Subject: [PATCH 1/7] fix(deploy): report archive verification failure reason --- tools/check-site-archive.mts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/check-site-archive.mts b/tools/check-site-archive.mts index ddc92b6..8007b49 100644 --- a/tools/check-site-archive.mts +++ b/tools/check-site-archive.mts @@ -86,8 +86,9 @@ export async function assertSiteArchiveSafe(options: { if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { assertSiteArchiveSafe({ archive: process.argv[2] || "", siteUrl: process.argv[3] || "" }).then(() => { console.log("Deployment archive retention verified."); - }).catch(() => { + }).catch((error: unknown) => { console.error("Archive verification failed; rebuild with current retained assets before uploading."); + console.error(error instanceof Error ? error.message : "Unknown archive verification error."); process.exitCode = 1; }); } From bb587159c16ecf9297fcecdb35fe3f17c4b675d2 Mon Sep 17 00:00:00 2001 From: Radon / ELRdn Date: Thu, 29 Oct 2026 23:17:54 +0900 Subject: [PATCH 2/7] chore: inspect public shell comparison without deployment --- .github/workflows/deploy-appwrite-site.yml | 131 ++++----------------- 1 file changed, 22 insertions(+), 109 deletions(-) diff --git a/.github/workflows/deploy-appwrite-site.yml b/.github/workflows/deploy-appwrite-site.yml index 5942e09..6f24fed 100644 --- a/.github/workflows/deploy-appwrite-site.yml +++ b/.github/workflows/deploy-appwrite-site.yml @@ -1,123 +1,36 @@ -name: Deploy Appwrite Site - +name: Diagnose public shell comparison on: workflow_dispatch: - inputs: - retention_bootstrap: - description: "First retention release only: seed from the identical active build (keep existing 240s cache gate)" - type: boolean - default: false - permissions: contents: read - -concurrency: - group: appwrite-site-production - cancel-in-progress: false - jobs: - deploy: + diagnose: runs-on: ubuntu-latest - environment: production steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - cache: npm - - run: npm ci - - name: Generate public deployment configuration - run: npm run generate-release-config - env: - APPWRITE_ENDPOINT: ${{ vars.APPWRITE_ENDPOINT }} - APPWRITE_PROJECT_ID: ${{ vars.APPWRITE_PROJECT_ID }} - APPWRITE_DATABASE_ID: ${{ vars.APPWRITE_DATABASE_ID }} - APPWRITE_STATE_TABLE_ID: ${{ vars.APPWRITE_STATE_TABLE_ID }} - APPWRITE_LEGACY_TABLE_ID: ${{ vars.APPWRITE_LEGACY_TABLE_ID }} - WORKER_BASE_URL: ${{ vars.WORKER_BASE_URL }} - MCP_BASE_URL: ${{ vars.MCP_BASE_URL || 'https://mcp.guilduo.com' }} - PROVIDER_OAUTH_BASE_URL: ${{ vars.PROVIDER_OAUTH_BASE_URL || vars.WORKER_BASE_URL }} - MCP_ALLOWED_ORIGINS: ${{ vars.MCP_ALLOWED_ORIGINS }} - WEB_APP_URL: ${{ vars.WEB_APP_URL }} - PUBLIC_SITE_URL: ${{ vars.PUBLIC_SITE_URL || 'https://guilduo.com' }} - SOURCE_URL: ${{ vars.SOURCE_URL }} - GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }} - EXTERNAL_OAUTH_ENABLED: ${{ vars.EXTERNAL_OAUTH_ENABLED || 'false' }} - JOIN_GUILD_URL: ${{ vars.JOIN_GUILD_URL || 'https://app.guilduo.com/' }} - D1_DATABASE_NAME: ${{ vars.D1_DATABASE_NAME }} - D1_DATABASE_ID: ${{ vars.D1_DATABASE_ID }} - KV_NAMESPACE_ID: ${{ vars.KV_NAMESPACE_ID }} - R2_BUCKET_NAME: ${{ vars.R2_BUCKET_NAME }} - WEB_API_ROUTE_ZONE_ID: ${{ vars.WEB_API_ROUTE_ZONE_ID }} - WEB_API_ROUTE_MANAGEMENT: ${{ vars.WEB_API_ROUTE_MANAGEMENT || 'wrangler' }} - WEB_API_BROWSER_ENABLED: ${{ vars.WEB_API_BROWSER_ENABLED || 'false' }} - - run: npm run check - - run: npm run brand:assets -- --check - - run: npm test - - run: npm run build - env: - WEB_APP_URL: ${{ vars.WEB_APP_URL }} - PUBLIC_SITE_URL: ${{ vars.PUBLIC_SITE_URL || 'https://guilduo.com' }} - - name: Verify launch candidate before deployment - shell: bash + - name: Compare public shell responses without deploying run: | - npm run preview -- --host 127.0.0.1 --port 4175 --strictPort > /tmp/guilduo-launch-preview.log 2>&1 & - preview_pid=$! - trap 'kill "$preview_pid"' EXIT - for attempt in {1..30}; do curl -fsS http://127.0.0.1:4175/privacy/ >/dev/null && break; sleep 1; done - npm run launch:verify -- http://127.0.0.1:4175 --built - npm run docs:verify -- http://127.0.0.1:4175 - npm run solutions:verify -- http://127.0.0.1:4175 - - name: Verify Web API route before enabling browser transport - if: vars.WEB_API_BROWSER_ENABLED == 'true' - run: node --import tsx tools/check-web-api-route.mts "$WEB_APP_URL" - env: - WEB_APP_URL: ${{ vars.WEB_APP_URL }} - - name: Ensure missing static documents return 404 - env: - APPWRITE_SITE_ENDPOINT: ${{ vars.APPWRITE_SITE_ENDPOINT }} - APPWRITE_PROJECT_ID: ${{ vars.APPWRITE_PROJECT_ID }} - APPWRITE_SITE_ID: ${{ vars.APPWRITE_SITE_ID }} - APPWRITE_DEPLOY_KEY: ${{ secrets.APPWRITE_DEPLOY_KEY }} - run: node --import tsx tools/ensure-static-site-routing.mts dist - - name: Upload and activate Appwrite Site - shell: bash - env: - APPWRITE_SITE_ENDPOINT: ${{ vars.APPWRITE_SITE_ENDPOINT }} - APPWRITE_PROJECT_ID: ${{ vars.APPWRITE_PROJECT_ID }} - APPWRITE_SITE_ID: ${{ vars.APPWRITE_SITE_ID }} - APPWRITE_DEPLOY_KEY: ${{ secrets.APPWRITE_DEPLOY_KEY }} - APPWRITE_SITE_URL: ${{ vars.WEB_APP_URL || 'https://app.guilduo.com' }} - RETENTION_BOOTSTRAP: ${{ inputs.retention_bootstrap }} - run: | - node --import tsx tools/check-site-deploy-cache.mts "$APPWRITE_SITE_URL" - retention_args=("$APPWRITE_SITE_URL" dist) - if [[ "$RETENTION_BOOTSTRAP" == "true" ]]; then retention_args+=(--bootstrap); fi - node --import tsx tools/retain-site-assets.mts "${retention_args[@]}" - archive="$RUNNER_TEMP/guilduo-site.tar.gz" - tar -czf "$archive" -C dist . - bash tools/deploy-appwrite-site.sh "$archive" - - name: Verify published Docs and missing-path status - env: - PUBLIC_SITE_URL: ${{ vars.PUBLIC_SITE_URL || 'https://guilduo.com' }} - run: | - node --input-type=module <<'NODE' - import assert from 'node:assert/strict'; - const origin = process.env.PUBLIC_SITE_URL; - for (const path of ['/docs/', '/docs/en/']) { - const response = await fetch(new URL(path, origin)); - assert.equal(response.status, 200); - assert.ok((await response.text()).includes('class="doc-main"'), 'Static Docs body missing'); - } - for (const prefix of ['', 'en/']) for (const slug of ['mcp-task-management', 'ai-agent-handoff']) { - const path = '/solutions/' + prefix + slug + '/'; - const response = await fetch(new URL(path, origin)); - assert.equal(response.status, 200); - assert.ok((await response.text()).includes('class="solution-main shell"'), 'Static solution body missing'); + node --experimental-strip-types --input-type=module <<'NODE' + import { normalizePublicShell, previousShellProbe } from './tools/retain-site-assets.mts'; + const origin = new URL('https://app.guilduo.com'); + const read = async () => { + const response = await fetch(new URL('/next/relay-forge/', origin), {redirect:'error'}); + if (!response.ok) throw new Error('Public shell HTTP ' + response.status); + return response.text(); + }; + const captured = await read(); + for (let i=0; i<3; i++) { + const active = await read(); + for (const [kind, a, b] of [ + ['source', normalizePublicShell(captured), normalizePublicShell(active)], + ['probe', normalizePublicShell(previousShellProbe(captured,origin)), normalizePublicShell(previousShellProbe(active,origin))] + ]) { + let at = 0; + while (at < a.length && a[at] === b[at]) at++; + console.log(JSON.stringify({i,kind,equal:a===b,lengths:[a.length,b.length],at,captured:a.slice(Math.max(0,at-100),at+180),active:b.slice(Math.max(0,at-100),at+180)})); + } } - const missingSolution = await fetch(new URL('/solutions/deployment-404-check/', origin)); - assert.equal(missingSolution.status, 404, 'Missing solution path must return HTTP 404'); - const missing = await fetch(new URL('/docs/deployment-404-check/', origin)); - assert.equal(missing.status, 404, 'Missing Docs path must return HTTP 404'); - console.log('Published bilingual Docs and real HTTP 404 verified.'); NODE From 5ec3f3232d9df193a254d4710d91dc2796a16c20 Mon Sep 17 00:00:00 2001 From: Radon / ELRdn Date: Thu, 29 Oct 2026 23:20:45 +0900 Subject: [PATCH 3/7] fix(deploy): recognize current Cloudflare JSD request metadata --- .github/workflows/deploy-appwrite-site.yml | 131 +++++++++++++++++---- tests/site-archive.test.ts | 6 +- tools/retain-site-assets.mts | 2 +- 3 files changed, 114 insertions(+), 25 deletions(-) diff --git a/.github/workflows/deploy-appwrite-site.yml b/.github/workflows/deploy-appwrite-site.yml index 6f24fed..5942e09 100644 --- a/.github/workflows/deploy-appwrite-site.yml +++ b/.github/workflows/deploy-appwrite-site.yml @@ -1,36 +1,123 @@ -name: Diagnose public shell comparison +name: Deploy Appwrite Site + on: workflow_dispatch: + inputs: + retention_bootstrap: + description: "First retention release only: seed from the identical active build (keep existing 240s cache gate)" + type: boolean + default: false + permissions: contents: read + +concurrency: + group: appwrite-site-production + cancel-in-progress: false + jobs: - diagnose: + deploy: runs-on: ubuntu-latest + environment: production steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - - name: Compare public shell responses without deploying + cache: npm + - run: npm ci + - name: Generate public deployment configuration + run: npm run generate-release-config + env: + APPWRITE_ENDPOINT: ${{ vars.APPWRITE_ENDPOINT }} + APPWRITE_PROJECT_ID: ${{ vars.APPWRITE_PROJECT_ID }} + APPWRITE_DATABASE_ID: ${{ vars.APPWRITE_DATABASE_ID }} + APPWRITE_STATE_TABLE_ID: ${{ vars.APPWRITE_STATE_TABLE_ID }} + APPWRITE_LEGACY_TABLE_ID: ${{ vars.APPWRITE_LEGACY_TABLE_ID }} + WORKER_BASE_URL: ${{ vars.WORKER_BASE_URL }} + MCP_BASE_URL: ${{ vars.MCP_BASE_URL || 'https://mcp.guilduo.com' }} + PROVIDER_OAUTH_BASE_URL: ${{ vars.PROVIDER_OAUTH_BASE_URL || vars.WORKER_BASE_URL }} + MCP_ALLOWED_ORIGINS: ${{ vars.MCP_ALLOWED_ORIGINS }} + WEB_APP_URL: ${{ vars.WEB_APP_URL }} + PUBLIC_SITE_URL: ${{ vars.PUBLIC_SITE_URL || 'https://guilduo.com' }} + SOURCE_URL: ${{ vars.SOURCE_URL }} + GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }} + EXTERNAL_OAUTH_ENABLED: ${{ vars.EXTERNAL_OAUTH_ENABLED || 'false' }} + JOIN_GUILD_URL: ${{ vars.JOIN_GUILD_URL || 'https://app.guilduo.com/' }} + D1_DATABASE_NAME: ${{ vars.D1_DATABASE_NAME }} + D1_DATABASE_ID: ${{ vars.D1_DATABASE_ID }} + KV_NAMESPACE_ID: ${{ vars.KV_NAMESPACE_ID }} + R2_BUCKET_NAME: ${{ vars.R2_BUCKET_NAME }} + WEB_API_ROUTE_ZONE_ID: ${{ vars.WEB_API_ROUTE_ZONE_ID }} + WEB_API_ROUTE_MANAGEMENT: ${{ vars.WEB_API_ROUTE_MANAGEMENT || 'wrangler' }} + WEB_API_BROWSER_ENABLED: ${{ vars.WEB_API_BROWSER_ENABLED || 'false' }} + - run: npm run check + - run: npm run brand:assets -- --check + - run: npm test + - run: npm run build + env: + WEB_APP_URL: ${{ vars.WEB_APP_URL }} + PUBLIC_SITE_URL: ${{ vars.PUBLIC_SITE_URL || 'https://guilduo.com' }} + - name: Verify launch candidate before deployment + shell: bash run: | - node --experimental-strip-types --input-type=module <<'NODE' - import { normalizePublicShell, previousShellProbe } from './tools/retain-site-assets.mts'; - const origin = new URL('https://app.guilduo.com'); - const read = async () => { - const response = await fetch(new URL('/next/relay-forge/', origin), {redirect:'error'}); - if (!response.ok) throw new Error('Public shell HTTP ' + response.status); - return response.text(); - }; - const captured = await read(); - for (let i=0; i<3; i++) { - const active = await read(); - for (const [kind, a, b] of [ - ['source', normalizePublicShell(captured), normalizePublicShell(active)], - ['probe', normalizePublicShell(previousShellProbe(captured,origin)), normalizePublicShell(previousShellProbe(active,origin))] - ]) { - let at = 0; - while (at < a.length && a[at] === b[at]) at++; - console.log(JSON.stringify({i,kind,equal:a===b,lengths:[a.length,b.length],at,captured:a.slice(Math.max(0,at-100),at+180),active:b.slice(Math.max(0,at-100),at+180)})); - } + npm run preview -- --host 127.0.0.1 --port 4175 --strictPort > /tmp/guilduo-launch-preview.log 2>&1 & + preview_pid=$! + trap 'kill "$preview_pid"' EXIT + for attempt in {1..30}; do curl -fsS http://127.0.0.1:4175/privacy/ >/dev/null && break; sleep 1; done + npm run launch:verify -- http://127.0.0.1:4175 --built + npm run docs:verify -- http://127.0.0.1:4175 + npm run solutions:verify -- http://127.0.0.1:4175 + - name: Verify Web API route before enabling browser transport + if: vars.WEB_API_BROWSER_ENABLED == 'true' + run: node --import tsx tools/check-web-api-route.mts "$WEB_APP_URL" + env: + WEB_APP_URL: ${{ vars.WEB_APP_URL }} + - name: Ensure missing static documents return 404 + env: + APPWRITE_SITE_ENDPOINT: ${{ vars.APPWRITE_SITE_ENDPOINT }} + APPWRITE_PROJECT_ID: ${{ vars.APPWRITE_PROJECT_ID }} + APPWRITE_SITE_ID: ${{ vars.APPWRITE_SITE_ID }} + APPWRITE_DEPLOY_KEY: ${{ secrets.APPWRITE_DEPLOY_KEY }} + run: node --import tsx tools/ensure-static-site-routing.mts dist + - name: Upload and activate Appwrite Site + shell: bash + env: + APPWRITE_SITE_ENDPOINT: ${{ vars.APPWRITE_SITE_ENDPOINT }} + APPWRITE_PROJECT_ID: ${{ vars.APPWRITE_PROJECT_ID }} + APPWRITE_SITE_ID: ${{ vars.APPWRITE_SITE_ID }} + APPWRITE_DEPLOY_KEY: ${{ secrets.APPWRITE_DEPLOY_KEY }} + APPWRITE_SITE_URL: ${{ vars.WEB_APP_URL || 'https://app.guilduo.com' }} + RETENTION_BOOTSTRAP: ${{ inputs.retention_bootstrap }} + run: | + node --import tsx tools/check-site-deploy-cache.mts "$APPWRITE_SITE_URL" + retention_args=("$APPWRITE_SITE_URL" dist) + if [[ "$RETENTION_BOOTSTRAP" == "true" ]]; then retention_args+=(--bootstrap); fi + node --import tsx tools/retain-site-assets.mts "${retention_args[@]}" + archive="$RUNNER_TEMP/guilduo-site.tar.gz" + tar -czf "$archive" -C dist . + bash tools/deploy-appwrite-site.sh "$archive" + - name: Verify published Docs and missing-path status + env: + PUBLIC_SITE_URL: ${{ vars.PUBLIC_SITE_URL || 'https://guilduo.com' }} + run: | + node --input-type=module <<'NODE' + import assert from 'node:assert/strict'; + const origin = process.env.PUBLIC_SITE_URL; + for (const path of ['/docs/', '/docs/en/']) { + const response = await fetch(new URL(path, origin)); + assert.equal(response.status, 200); + assert.ok((await response.text()).includes('class="doc-main"'), 'Static Docs body missing'); + } + for (const prefix of ['', 'en/']) for (const slug of ['mcp-task-management', 'ai-agent-handoff']) { + const path = '/solutions/' + prefix + slug + '/'; + const response = await fetch(new URL(path, origin)); + assert.equal(response.status, 200); + assert.ok((await response.text()).includes('class="solution-main shell"'), 'Static solution body missing'); } + const missingSolution = await fetch(new URL('/solutions/deployment-404-check/', origin)); + assert.equal(missingSolution.status, 404, 'Missing solution path must return HTTP 404'); + const missing = await fetch(new URL('/docs/deployment-404-check/', origin)); + assert.equal(missing.status, 404, 'Missing Docs path must return HTTP 404'); + console.log('Published bilingual Docs and real HTTP 404 verified.'); NODE diff --git a/tests/site-archive.test.ts b/tests/site-archive.test.ts index b8a68d1..3b10427 100644 --- a/tests/site-archive.test.ts +++ b/tests/site-archive.test.ts @@ -61,8 +61,10 @@ test("actual tar retains both the previous entry and its lazy imports", async t await check(await archive(t, members())); }); -test("v2 accepts only changing Cloudflare JSD request metadata while preserving all HTML and script checks", async t => { - const jsd = (ray: string, time: string) => ``; +for (const wrapper of ["legacy", "parent-location"]) test(`v2 accepts only Cloudflare JSD request metadata changes (${wrapper})`, async t => { + const continuation = wrapper === "legacy" ? "var a=document.createElement('script');a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);" + : "var l=window.parent.location,o;try{o=new URL(l.origin).origin}catch(e){try{o=new URL(new URL(l.href).origin).origin}catch(e){o=''}}var u='/cdn-cgi/challenge-platform/scripts/jsd/main.js';"; + const jsd = (ray: string, time: string) => ``; const captured = shell(oldName).replace("", jsd("a4942470f8500900", "MTc5MTc4NzUzMg==") + ""); const active = captured.replace("a4942470f8500900", "a49426f59e37cb83").replace("MTc5MTc4NzUzMg==", "MTc5MTc4NzYzNg=="); const probe = previousShellProbe(captured, new URL(origin)); diff --git a/tools/retain-site-assets.mts b/tools/retain-site-assets.mts index 6ca115b..283fd5e 100644 --- a/tools/retain-site-assets.mts +++ b/tools/retain-site-assets.mts @@ -9,7 +9,7 @@ export const PREVIOUS_SHELL_PATH = "/deployment-check/previous-shell.html"; export const PREVIOUS_SHELL_META_PATH = "/deployment-check/previous-shell.json"; export function normalizePublicShell(html: string): string { // Compare only this known Cloudflare JSD request metadata; keep its code and the stored probe intact. - return html.replace(/(d\.innerHTML="window\.__CF\$cv\$params=\{r:')[a-f0-9]{16}(',t:')([A-Za-z0-9+/]+={0,2})('\};var a=document\.createElement\('script'\);a\.src='\/cdn-cgi\/challenge-platform\/scripts\/jsd\/main\.js';)/g, + return html.replace(/(d\.innerHTML="window\.__CF\$cv\$params=\{r:')[a-f0-9]{16}(',t:')([A-Za-z0-9+/]+={0,2})('\};(?:var a=document\.createElement\('script'\);a\.src='\/cdn-cgi\/challenge-platform\/scripts\/jsd\/main\.js';|var l=window\.parent\.location,o;))/g, (match, before: string, between: string, time: string, after: string) => /^\d{10}$/.test(Buffer.from(time, "base64").toString("utf8")) ? `${before}REQUEST_RAY${between}REQUEST_TIME${after}` : match); } export function previousShellProbe(html: string, origin: URL): string { From fd8c21a77ffc6142a6f2ae9668d2a9da9afdac33 Mon Sep 17 00:00:00 2001 From: Radon / ELRdn Date: Thu, 29 Oct 2026 23:25:05 +0900 Subject: [PATCH 4/7] chore: inspect remaining public shell mismatch --- .github/workflows/deploy-appwrite-site.yml | 131 ++++----------------- 1 file changed, 22 insertions(+), 109 deletions(-) diff --git a/.github/workflows/deploy-appwrite-site.yml b/.github/workflows/deploy-appwrite-site.yml index 5942e09..6f24fed 100644 --- a/.github/workflows/deploy-appwrite-site.yml +++ b/.github/workflows/deploy-appwrite-site.yml @@ -1,123 +1,36 @@ -name: Deploy Appwrite Site - +name: Diagnose public shell comparison on: workflow_dispatch: - inputs: - retention_bootstrap: - description: "First retention release only: seed from the identical active build (keep existing 240s cache gate)" - type: boolean - default: false - permissions: contents: read - -concurrency: - group: appwrite-site-production - cancel-in-progress: false - jobs: - deploy: + diagnose: runs-on: ubuntu-latest - environment: production steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - cache: npm - - run: npm ci - - name: Generate public deployment configuration - run: npm run generate-release-config - env: - APPWRITE_ENDPOINT: ${{ vars.APPWRITE_ENDPOINT }} - APPWRITE_PROJECT_ID: ${{ vars.APPWRITE_PROJECT_ID }} - APPWRITE_DATABASE_ID: ${{ vars.APPWRITE_DATABASE_ID }} - APPWRITE_STATE_TABLE_ID: ${{ vars.APPWRITE_STATE_TABLE_ID }} - APPWRITE_LEGACY_TABLE_ID: ${{ vars.APPWRITE_LEGACY_TABLE_ID }} - WORKER_BASE_URL: ${{ vars.WORKER_BASE_URL }} - MCP_BASE_URL: ${{ vars.MCP_BASE_URL || 'https://mcp.guilduo.com' }} - PROVIDER_OAUTH_BASE_URL: ${{ vars.PROVIDER_OAUTH_BASE_URL || vars.WORKER_BASE_URL }} - MCP_ALLOWED_ORIGINS: ${{ vars.MCP_ALLOWED_ORIGINS }} - WEB_APP_URL: ${{ vars.WEB_APP_URL }} - PUBLIC_SITE_URL: ${{ vars.PUBLIC_SITE_URL || 'https://guilduo.com' }} - SOURCE_URL: ${{ vars.SOURCE_URL }} - GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }} - EXTERNAL_OAUTH_ENABLED: ${{ vars.EXTERNAL_OAUTH_ENABLED || 'false' }} - JOIN_GUILD_URL: ${{ vars.JOIN_GUILD_URL || 'https://app.guilduo.com/' }} - D1_DATABASE_NAME: ${{ vars.D1_DATABASE_NAME }} - D1_DATABASE_ID: ${{ vars.D1_DATABASE_ID }} - KV_NAMESPACE_ID: ${{ vars.KV_NAMESPACE_ID }} - R2_BUCKET_NAME: ${{ vars.R2_BUCKET_NAME }} - WEB_API_ROUTE_ZONE_ID: ${{ vars.WEB_API_ROUTE_ZONE_ID }} - WEB_API_ROUTE_MANAGEMENT: ${{ vars.WEB_API_ROUTE_MANAGEMENT || 'wrangler' }} - WEB_API_BROWSER_ENABLED: ${{ vars.WEB_API_BROWSER_ENABLED || 'false' }} - - run: npm run check - - run: npm run brand:assets -- --check - - run: npm test - - run: npm run build - env: - WEB_APP_URL: ${{ vars.WEB_APP_URL }} - PUBLIC_SITE_URL: ${{ vars.PUBLIC_SITE_URL || 'https://guilduo.com' }} - - name: Verify launch candidate before deployment - shell: bash + - name: Compare public shell responses without deploying run: | - npm run preview -- --host 127.0.0.1 --port 4175 --strictPort > /tmp/guilduo-launch-preview.log 2>&1 & - preview_pid=$! - trap 'kill "$preview_pid"' EXIT - for attempt in {1..30}; do curl -fsS http://127.0.0.1:4175/privacy/ >/dev/null && break; sleep 1; done - npm run launch:verify -- http://127.0.0.1:4175 --built - npm run docs:verify -- http://127.0.0.1:4175 - npm run solutions:verify -- http://127.0.0.1:4175 - - name: Verify Web API route before enabling browser transport - if: vars.WEB_API_BROWSER_ENABLED == 'true' - run: node --import tsx tools/check-web-api-route.mts "$WEB_APP_URL" - env: - WEB_APP_URL: ${{ vars.WEB_APP_URL }} - - name: Ensure missing static documents return 404 - env: - APPWRITE_SITE_ENDPOINT: ${{ vars.APPWRITE_SITE_ENDPOINT }} - APPWRITE_PROJECT_ID: ${{ vars.APPWRITE_PROJECT_ID }} - APPWRITE_SITE_ID: ${{ vars.APPWRITE_SITE_ID }} - APPWRITE_DEPLOY_KEY: ${{ secrets.APPWRITE_DEPLOY_KEY }} - run: node --import tsx tools/ensure-static-site-routing.mts dist - - name: Upload and activate Appwrite Site - shell: bash - env: - APPWRITE_SITE_ENDPOINT: ${{ vars.APPWRITE_SITE_ENDPOINT }} - APPWRITE_PROJECT_ID: ${{ vars.APPWRITE_PROJECT_ID }} - APPWRITE_SITE_ID: ${{ vars.APPWRITE_SITE_ID }} - APPWRITE_DEPLOY_KEY: ${{ secrets.APPWRITE_DEPLOY_KEY }} - APPWRITE_SITE_URL: ${{ vars.WEB_APP_URL || 'https://app.guilduo.com' }} - RETENTION_BOOTSTRAP: ${{ inputs.retention_bootstrap }} - run: | - node --import tsx tools/check-site-deploy-cache.mts "$APPWRITE_SITE_URL" - retention_args=("$APPWRITE_SITE_URL" dist) - if [[ "$RETENTION_BOOTSTRAP" == "true" ]]; then retention_args+=(--bootstrap); fi - node --import tsx tools/retain-site-assets.mts "${retention_args[@]}" - archive="$RUNNER_TEMP/guilduo-site.tar.gz" - tar -czf "$archive" -C dist . - bash tools/deploy-appwrite-site.sh "$archive" - - name: Verify published Docs and missing-path status - env: - PUBLIC_SITE_URL: ${{ vars.PUBLIC_SITE_URL || 'https://guilduo.com' }} - run: | - node --input-type=module <<'NODE' - import assert from 'node:assert/strict'; - const origin = process.env.PUBLIC_SITE_URL; - for (const path of ['/docs/', '/docs/en/']) { - const response = await fetch(new URL(path, origin)); - assert.equal(response.status, 200); - assert.ok((await response.text()).includes('class="doc-main"'), 'Static Docs body missing'); - } - for (const prefix of ['', 'en/']) for (const slug of ['mcp-task-management', 'ai-agent-handoff']) { - const path = '/solutions/' + prefix + slug + '/'; - const response = await fetch(new URL(path, origin)); - assert.equal(response.status, 200); - assert.ok((await response.text()).includes('class="solution-main shell"'), 'Static solution body missing'); + node --experimental-strip-types --input-type=module <<'NODE' + import { normalizePublicShell, previousShellProbe } from './tools/retain-site-assets.mts'; + const origin = new URL('https://app.guilduo.com'); + const read = async () => { + const response = await fetch(new URL('/next/relay-forge/', origin), {redirect:'error'}); + if (!response.ok) throw new Error('Public shell HTTP ' + response.status); + return response.text(); + }; + const captured = await read(); + for (let i=0; i<3; i++) { + const active = await read(); + for (const [kind, a, b] of [ + ['source', normalizePublicShell(captured), normalizePublicShell(active)], + ['probe', normalizePublicShell(previousShellProbe(captured,origin)), normalizePublicShell(previousShellProbe(active,origin))] + ]) { + let at = 0; + while (at < a.length && a[at] === b[at]) at++; + console.log(JSON.stringify({i,kind,equal:a===b,lengths:[a.length,b.length],at,captured:a.slice(Math.max(0,at-100),at+180),active:b.slice(Math.max(0,at-100),at+180)})); + } } - const missingSolution = await fetch(new URL('/solutions/deployment-404-check/', origin)); - assert.equal(missingSolution.status, 404, 'Missing solution path must return HTTP 404'); - const missing = await fetch(new URL('/docs/deployment-404-check/', origin)); - assert.equal(missing.status, 404, 'Missing Docs path must return HTTP 404'); - console.log('Published bilingual Docs and real HTTP 404 verified.'); NODE From f95955ad323b3bf89cb30212657e0beda529d3ae Mon Sep 17 00:00:00 2001 From: Radon / ELRdn Date: Thu, 29 Oct 2026 23:27:44 +0900 Subject: [PATCH 5/7] fix(deploy): normalize JSD request ID query as metadata --- tests/site-archive.test.ts | 6 +++--- tools/retain-site-assets.mts | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/site-archive.test.ts b/tests/site-archive.test.ts index 3b10427..5be03d1 100644 --- a/tests/site-archive.test.ts +++ b/tests/site-archive.test.ts @@ -63,10 +63,10 @@ test("actual tar retains both the previous entry and its lazy imports", async t for (const wrapper of ["legacy", "parent-location"]) test(`v2 accepts only Cloudflare JSD request metadata changes (${wrapper})`, async t => { const continuation = wrapper === "legacy" ? "var a=document.createElement('script');a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);" - : "var l=window.parent.location,o;try{o=new URL(l.origin).origin}catch(e){try{o=new URL(new URL(l.href).origin).origin}catch(e){o=''}}var u='/cdn-cgi/challenge-platform/scripts/jsd/main.js';"; - const jsd = (ray: string, time: string) => ``; + : "var l=window.parent.location,o;try{o=new URL(l.origin).origin}catch(e){try{o=new URL(new URL(l.href).origin).origin}catch(e){o=''}}var u='/cdn-cgi/challenge-platform/scripts/jsd/main.js?ray=REQUEST_RAY',a=document.createElement('script');a.src=o?new URL(u,o).href:u;document.getElementsByTagName('head')[0].appendChild(a);"; + const jsd = (ray: string, time: string) => ``; const captured = shell(oldName).replace("", jsd("a4942470f8500900", "MTc5MTc4NzUzMg==") + ""); - const active = captured.replace("a4942470f8500900", "a49426f59e37cb83").replace("MTc5MTc4NzUzMg==", "MTc5MTc4NzYzNg=="); + const active = captured.replaceAll("a4942470f8500900", "a49426f59e37cb83").replace("MTc5MTc4NzUzMg==", "MTc5MTc4NzYzNg=="); const probe = previousShellProbe(captured, new URL(origin)); const entries = members().map(item => item.name.endsWith("previous-shell.html") ? { ...item, text:probe } : item.name.endsWith("previous-shell.json") ? { ...item, text:JSON.stringify({version:2,capturedAt:now,sourcePath:"/next/relay-forge/",sourceSha256:sha(normalizePublicShell(captured)),probeSha256:sha(probe)}) } : item); diff --git a/tools/retain-site-assets.mts b/tools/retain-site-assets.mts index 283fd5e..1ead676 100644 --- a/tools/retain-site-assets.mts +++ b/tools/retain-site-assets.mts @@ -8,9 +8,10 @@ export const ASSET_RETENTION_MS = 48 * 60 * 60 * 1000; export const PREVIOUS_SHELL_PATH = "/deployment-check/previous-shell.html"; export const PREVIOUS_SHELL_META_PATH = "/deployment-check/previous-shell.json"; export function normalizePublicShell(html: string): string { - // Compare only this known Cloudflare JSD request metadata; keep its code and the stored probe intact. + // Normalize only known Cloudflare JSD request IDs/time, including the URL query; keep all code and the stored probe intact. return html.replace(/(d\.innerHTML="window\.__CF\$cv\$params=\{r:')[a-f0-9]{16}(',t:')([A-Za-z0-9+/]+={0,2})('\};(?:var a=document\.createElement\('script'\);a\.src='\/cdn-cgi\/challenge-platform\/scripts\/jsd\/main\.js';|var l=window\.parent\.location,o;))/g, - (match, before: string, between: string, time: string, after: string) => /^\d{10}$/.test(Buffer.from(time, "base64").toString("utf8")) ? `${before}REQUEST_RAY${between}REQUEST_TIME${after}` : match); + (match, before: string, between: string, time: string, after: string) => /^\d{10}$/.test(Buffer.from(time, "base64").toString("utf8")) ? `${before}REQUEST_RAY${between}REQUEST_TIME${after}` : match) + .replace(/(var u='\/cdn-cgi\/challenge-platform\/scripts\/jsd\/main\.js\?ray=)[a-f0-9]{16}(',a=document\.createElement\('script'\);)/g, "$1REQUEST_RAY$2"); } export function previousShellProbe(html: string, origin: URL): string { if (!/]*)?>/i.test(html) || / Date: Thu, 29 Oct 2026 23:30:02 +0900 Subject: [PATCH 6/7] fix(deploy): compare exact known alternating Cloudflare loaders --- tests/site-archive.test.ts | 14 ++++++++------ tools/retain-site-assets.mts | 8 ++++++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/tests/site-archive.test.ts b/tests/site-archive.test.ts index 5be03d1..5df6685 100644 --- a/tests/site-archive.test.ts +++ b/tests/site-archive.test.ts @@ -61,12 +61,12 @@ test("actual tar retains both the previous entry and its lazy imports", async t await check(await archive(t, members())); }); -for (const wrapper of ["legacy", "parent-location"]) test(`v2 accepts only Cloudflare JSD request metadata changes (${wrapper})`, async t => { - const continuation = wrapper === "legacy" ? "var a=document.createElement('script');a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);" +for (const capturedWrapper of ["legacy", "parent-location"]) for (const activeWrapper of ["legacy", "parent-location"]) test(`v2 accepts known Cloudflare JSD wrappers (${capturedWrapper} to ${activeWrapper})`, async t => { + const continuation = (wrapper: string) => wrapper === "legacy" ? "var a=document.createElement('script');a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);" : "var l=window.parent.location,o;try{o=new URL(l.origin).origin}catch(e){try{o=new URL(new URL(l.href).origin).origin}catch(e){o=''}}var u='/cdn-cgi/challenge-platform/scripts/jsd/main.js?ray=REQUEST_RAY',a=document.createElement('script');a.src=o?new URL(u,o).href:u;document.getElementsByTagName('head')[0].appendChild(a);"; - const jsd = (ray: string, time: string) => ``; - const captured = shell(oldName).replace("", jsd("a4942470f8500900", "MTc5MTc4NzUzMg==") + ""); - const active = captured.replaceAll("a4942470f8500900", "a49426f59e37cb83").replace("MTc5MTc4NzUzMg==", "MTc5MTc4NzYzNg=="); + const jsd = (wrapper: string, ray: string, time: string) => ``; + const captured = shell(oldName).replace("", jsd(capturedWrapper, "a4942470f8500900", "MTc5MTc4NzUzMg==") + ""); + const active = shell(oldName).replace("", jsd(activeWrapper, "a49426f59e37cb83", "MTc5MTc4NzYzNg==") + ""); const probe = previousShellProbe(captured, new URL(origin)); const entries = members().map(item => item.name.endsWith("previous-shell.html") ? { ...item, text:probe } : item.name.endsWith("previous-shell.json") ? { ...item, text:JSON.stringify({version:2,capturedAt:now,sourcePath:"/next/relay-forge/",sourceSha256:sha(normalizePublicShell(captured)),probeSha256:sha(probe)}) } : item); @@ -74,7 +74,9 @@ for (const wrapper of ["legacy", "parent-location"]) test(`v2 accepts only Cloud const serve = (html: string): typeof fetch => async (input, options) => String(input).endsWith("retained-releases.json") ? server()(input, options) : new Response(html, {headers:{"content-type":"text/html"}}); await check(tar, serve(active)); - for (const altered of [active.replace("jsd/main.js", "jsd/altered.js"), active.replace("", ""), active.replace(oldName, newName), active.replace("MTc5MTc4NzYzNg==", "YWxlcnQoMSk=")]) { + const alterations = [active.replace("jsd/main.js", "jsd/altered.js"), active.replace("", ""), active.replace(oldName, newName), active.replace("MTc5MTc4NzYzNg==", "YWxlcnQoMSk="), active.replace("document.getElementsByTagName('head')[0].appendChild(a);", "document.body.appendChild(a);")]; + if (activeWrapper === "parent-location") alterations.push(active.replace("?ray=a49426f59e37cb83", "?ray=invalid")); + for (const altered of alterations) { await assert.rejects(check(tar, serve(altered)), /Previous-shell/); } const legacy = entries.map(item => item.name.endsWith("previous-shell.json") ? {...item,text:JSON.stringify({version:1,capturedAt:now,sourcePath:"/next/relay-forge/",sourceSha256:sha(captured),probeSha256:sha(probe)})}:item); diff --git a/tools/retain-site-assets.mts b/tools/retain-site-assets.mts index 1ead676..742ad07 100644 --- a/tools/retain-site-assets.mts +++ b/tools/retain-site-assets.mts @@ -8,10 +8,14 @@ export const ASSET_RETENTION_MS = 48 * 60 * 60 * 1000; export const PREVIOUS_SHELL_PATH = "/deployment-check/previous-shell.html"; export const PREVIOUS_SHELL_META_PATH = "/deployment-check/previous-shell.json"; export function normalizePublicShell(html: string): string { - // Normalize only known Cloudflare JSD request IDs/time, including the URL query; keep all code and the stored probe intact. + // Cloudflare alternates these two exact loaders per request; any other HTML/code change must still fail comparison. + const prefix = "d.innerHTML=\"window.__CF$cv$params={r:'REQUEST_RAY',t:'REQUEST_TIME'};"; + const legacy = "var a=document.createElement('script');a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);"; + const parent = "var l=window.parent.location,o;try{o=new URL(l.origin).origin}catch(e){try{o=new URL(new URL(l.href).origin).origin}catch(e){o=''}}var u='/cdn-cgi/challenge-platform/scripts/jsd/main.js?ray=REQUEST_RAY',a=document.createElement('script');a.src=o?new URL(u,o).href:u;document.getElementsByTagName('head')[0].appendChild(a);"; return html.replace(/(d\.innerHTML="window\.__CF\$cv\$params=\{r:')[a-f0-9]{16}(',t:')([A-Za-z0-9+/]+={0,2})('\};(?:var a=document\.createElement\('script'\);a\.src='\/cdn-cgi\/challenge-platform\/scripts\/jsd\/main\.js';|var l=window\.parent\.location,o;))/g, (match, before: string, between: string, time: string, after: string) => /^\d{10}$/.test(Buffer.from(time, "base64").toString("utf8")) ? `${before}REQUEST_RAY${between}REQUEST_TIME${after}` : match) - .replace(/(var u='\/cdn-cgi\/challenge-platform\/scripts\/jsd\/main\.js\?ray=)[a-f0-9]{16}(',a=document\.createElement\('script'\);)/g, "$1REQUEST_RAY$2"); + .replace(/(var u='\/cdn-cgi\/challenge-platform\/scripts\/jsd\/main\.js\?ray=)[a-f0-9]{16}(',a=document\.createElement\('script'\);)/g, "$1REQUEST_RAY$2") + .replaceAll(prefix + parent + '"', prefix + legacy + '"'); } export function previousShellProbe(html: string, origin: URL): string { if (!/]*)?>/i.test(html) || / Date: Thu, 29 Oct 2026 23:31:11 +0900 Subject: [PATCH 7/7] chore: restore production Site deployment workflow --- .github/workflows/deploy-appwrite-site.yml | 131 +++++++++++++++++---- 1 file changed, 109 insertions(+), 22 deletions(-) diff --git a/.github/workflows/deploy-appwrite-site.yml b/.github/workflows/deploy-appwrite-site.yml index 6f24fed..5942e09 100644 --- a/.github/workflows/deploy-appwrite-site.yml +++ b/.github/workflows/deploy-appwrite-site.yml @@ -1,36 +1,123 @@ -name: Diagnose public shell comparison +name: Deploy Appwrite Site + on: workflow_dispatch: + inputs: + retention_bootstrap: + description: "First retention release only: seed from the identical active build (keep existing 240s cache gate)" + type: boolean + default: false + permissions: contents: read + +concurrency: + group: appwrite-site-production + cancel-in-progress: false + jobs: - diagnose: + deploy: runs-on: ubuntu-latest + environment: production steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - - name: Compare public shell responses without deploying + cache: npm + - run: npm ci + - name: Generate public deployment configuration + run: npm run generate-release-config + env: + APPWRITE_ENDPOINT: ${{ vars.APPWRITE_ENDPOINT }} + APPWRITE_PROJECT_ID: ${{ vars.APPWRITE_PROJECT_ID }} + APPWRITE_DATABASE_ID: ${{ vars.APPWRITE_DATABASE_ID }} + APPWRITE_STATE_TABLE_ID: ${{ vars.APPWRITE_STATE_TABLE_ID }} + APPWRITE_LEGACY_TABLE_ID: ${{ vars.APPWRITE_LEGACY_TABLE_ID }} + WORKER_BASE_URL: ${{ vars.WORKER_BASE_URL }} + MCP_BASE_URL: ${{ vars.MCP_BASE_URL || 'https://mcp.guilduo.com' }} + PROVIDER_OAUTH_BASE_URL: ${{ vars.PROVIDER_OAUTH_BASE_URL || vars.WORKER_BASE_URL }} + MCP_ALLOWED_ORIGINS: ${{ vars.MCP_ALLOWED_ORIGINS }} + WEB_APP_URL: ${{ vars.WEB_APP_URL }} + PUBLIC_SITE_URL: ${{ vars.PUBLIC_SITE_URL || 'https://guilduo.com' }} + SOURCE_URL: ${{ vars.SOURCE_URL }} + GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }} + EXTERNAL_OAUTH_ENABLED: ${{ vars.EXTERNAL_OAUTH_ENABLED || 'false' }} + JOIN_GUILD_URL: ${{ vars.JOIN_GUILD_URL || 'https://app.guilduo.com/' }} + D1_DATABASE_NAME: ${{ vars.D1_DATABASE_NAME }} + D1_DATABASE_ID: ${{ vars.D1_DATABASE_ID }} + KV_NAMESPACE_ID: ${{ vars.KV_NAMESPACE_ID }} + R2_BUCKET_NAME: ${{ vars.R2_BUCKET_NAME }} + WEB_API_ROUTE_ZONE_ID: ${{ vars.WEB_API_ROUTE_ZONE_ID }} + WEB_API_ROUTE_MANAGEMENT: ${{ vars.WEB_API_ROUTE_MANAGEMENT || 'wrangler' }} + WEB_API_BROWSER_ENABLED: ${{ vars.WEB_API_BROWSER_ENABLED || 'false' }} + - run: npm run check + - run: npm run brand:assets -- --check + - run: npm test + - run: npm run build + env: + WEB_APP_URL: ${{ vars.WEB_APP_URL }} + PUBLIC_SITE_URL: ${{ vars.PUBLIC_SITE_URL || 'https://guilduo.com' }} + - name: Verify launch candidate before deployment + shell: bash run: | - node --experimental-strip-types --input-type=module <<'NODE' - import { normalizePublicShell, previousShellProbe } from './tools/retain-site-assets.mts'; - const origin = new URL('https://app.guilduo.com'); - const read = async () => { - const response = await fetch(new URL('/next/relay-forge/', origin), {redirect:'error'}); - if (!response.ok) throw new Error('Public shell HTTP ' + response.status); - return response.text(); - }; - const captured = await read(); - for (let i=0; i<3; i++) { - const active = await read(); - for (const [kind, a, b] of [ - ['source', normalizePublicShell(captured), normalizePublicShell(active)], - ['probe', normalizePublicShell(previousShellProbe(captured,origin)), normalizePublicShell(previousShellProbe(active,origin))] - ]) { - let at = 0; - while (at < a.length && a[at] === b[at]) at++; - console.log(JSON.stringify({i,kind,equal:a===b,lengths:[a.length,b.length],at,captured:a.slice(Math.max(0,at-100),at+180),active:b.slice(Math.max(0,at-100),at+180)})); - } + npm run preview -- --host 127.0.0.1 --port 4175 --strictPort > /tmp/guilduo-launch-preview.log 2>&1 & + preview_pid=$! + trap 'kill "$preview_pid"' EXIT + for attempt in {1..30}; do curl -fsS http://127.0.0.1:4175/privacy/ >/dev/null && break; sleep 1; done + npm run launch:verify -- http://127.0.0.1:4175 --built + npm run docs:verify -- http://127.0.0.1:4175 + npm run solutions:verify -- http://127.0.0.1:4175 + - name: Verify Web API route before enabling browser transport + if: vars.WEB_API_BROWSER_ENABLED == 'true' + run: node --import tsx tools/check-web-api-route.mts "$WEB_APP_URL" + env: + WEB_APP_URL: ${{ vars.WEB_APP_URL }} + - name: Ensure missing static documents return 404 + env: + APPWRITE_SITE_ENDPOINT: ${{ vars.APPWRITE_SITE_ENDPOINT }} + APPWRITE_PROJECT_ID: ${{ vars.APPWRITE_PROJECT_ID }} + APPWRITE_SITE_ID: ${{ vars.APPWRITE_SITE_ID }} + APPWRITE_DEPLOY_KEY: ${{ secrets.APPWRITE_DEPLOY_KEY }} + run: node --import tsx tools/ensure-static-site-routing.mts dist + - name: Upload and activate Appwrite Site + shell: bash + env: + APPWRITE_SITE_ENDPOINT: ${{ vars.APPWRITE_SITE_ENDPOINT }} + APPWRITE_PROJECT_ID: ${{ vars.APPWRITE_PROJECT_ID }} + APPWRITE_SITE_ID: ${{ vars.APPWRITE_SITE_ID }} + APPWRITE_DEPLOY_KEY: ${{ secrets.APPWRITE_DEPLOY_KEY }} + APPWRITE_SITE_URL: ${{ vars.WEB_APP_URL || 'https://app.guilduo.com' }} + RETENTION_BOOTSTRAP: ${{ inputs.retention_bootstrap }} + run: | + node --import tsx tools/check-site-deploy-cache.mts "$APPWRITE_SITE_URL" + retention_args=("$APPWRITE_SITE_URL" dist) + if [[ "$RETENTION_BOOTSTRAP" == "true" ]]; then retention_args+=(--bootstrap); fi + node --import tsx tools/retain-site-assets.mts "${retention_args[@]}" + archive="$RUNNER_TEMP/guilduo-site.tar.gz" + tar -czf "$archive" -C dist . + bash tools/deploy-appwrite-site.sh "$archive" + - name: Verify published Docs and missing-path status + env: + PUBLIC_SITE_URL: ${{ vars.PUBLIC_SITE_URL || 'https://guilduo.com' }} + run: | + node --input-type=module <<'NODE' + import assert from 'node:assert/strict'; + const origin = process.env.PUBLIC_SITE_URL; + for (const path of ['/docs/', '/docs/en/']) { + const response = await fetch(new URL(path, origin)); + assert.equal(response.status, 200); + assert.ok((await response.text()).includes('class="doc-main"'), 'Static Docs body missing'); + } + for (const prefix of ['', 'en/']) for (const slug of ['mcp-task-management', 'ai-agent-handoff']) { + const path = '/solutions/' + prefix + slug + '/'; + const response = await fetch(new URL(path, origin)); + assert.equal(response.status, 200); + assert.ok((await response.text()).includes('class="solution-main shell"'), 'Static solution body missing'); } + const missingSolution = await fetch(new URL('/solutions/deployment-404-check/', origin)); + assert.equal(missingSolution.status, 404, 'Missing solution path must return HTTP 404'); + const missing = await fetch(new URL('/docs/deployment-404-check/', origin)); + assert.equal(missing.status, 404, 'Missing Docs path must return HTTP 404'); + console.log('Published bilingual Docs and real HTTP 404 verified.'); NODE