From 764ce552c2f46a880ecd0ae1f7756233f4bbba79 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:20:22 +0800 Subject: [PATCH] fix(console): project bounded baseline version schema for diagnosis Co-Authored-By: Codex --- .../deploy-strategy-switch-console.yml | 58 +++++++++++++++++++ tests/console_deploy_safety_validation.mjs | 27 ++++++++- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-strategy-switch-console.yml b/.github/workflows/deploy-strategy-switch-console.yml index 5521f299..86efa0db 100644 --- a/.github/workflows/deploy-strategy-switch-console.yml +++ b/.github/workflows/deploy-strategy-switch-console.yml @@ -201,6 +201,64 @@ jobs: } JS + - name: Read fixed frontend baseline schema categories + if: inputs.release_frontend_assets + run: | + node --input-type=module <<'JS' + // Read-only diagnostic: keys, types and counts only. Never binding names, + // values, raw errors, credentials or response bodies. One fixed GET. + const fail = () => { throw new Error('frontend_schema_readback_failed'); }; + let httpStatus = null; + try { + const account = process.env.CLOUDFLARE_ACCOUNT_ID, token = process.env.CLOUDFLARE_API_TOKEN; + if (process.env.GITHUB_REF !== 'refs/heads/main' || !token || !/^[0-9a-f]{32}$/i.test(account ?? '')) fail(); + const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${account}/workers/scripts/qsl-strategy-switch-console/versions/2beea000-cce5-43a5-9255-9724f7318e83`, { + method: 'GET', redirect: 'error', signal: AbortSignal.timeout(15000), + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', 'Accept-Encoding': 'identity' }, + }); + httpStatus = response.status; + if (!response.ok) { await response.body?.cancel(); fail(); } + if (!/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '') || !['', 'identity'].includes(response.headers.get('content-encoding') ?? '')) fail(); + const length = response.headers.get('content-length'); + if (length !== null && (!/^\d+$/.test(length) || Number(length) > 524288)) fail(); + const reader = response.body?.getReader(); if (!reader) fail(); + let size = 0, text = ''; const decoder = new TextDecoder('utf-8', { fatal: true }); + try { + for (;;) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > 524288) fail(); text += decoder.decode(value, { stream: true }); } + text += decoder.decode(); + } finally { await reader.cancel(); } + if (length !== null && Number(length) !== size) fail(); + const body = JSON.parse(text); + if (body?.success !== true || (body.errors !== undefined && (!Array.isArray(body.errors) || body.errors.length))) fail(); + const kind = x => x === null ? 'null' : Array.isArray(x) ? 'array' : typeof x; + const keys = ['resources','metadata','number','annotations','startup_time_ms','bindings','script','script_runtime','etag','handlers','last_deployed_from','placement_mode','compatibility_date','compatibility_flags','usage_model','limits','migration_tag','exports','name','type','namespace_id','class_name','script_name','environment','text','json','dispatch_namespace','id','raw','tag','version_id','secret_name','store_id']; + const shape = x => { + if (!x || kind(x) !== 'object') return { kind: kind(x) }; + const entries = Object.entries(x); if (entries.length > 128) fail(); + return { kind: 'object', fields: Object.fromEntries(entries.filter(([k]) => keys.includes(k)).map(([k,v]) => [k,kind(v)])), unknown_fields: entries.filter(([k]) => !keys.includes(k)).length }; + }; + const resources = body.result?.resources, bindings = resources?.bindings; + const categories = []; + const types = ['plain_text','json','secret_text','kv_namespace','durable_object_namespace','service','r2_bucket','d1','ai','browser','analytics_engine','version_metadata','secret_key','secrets_store_secret','queue','workflow','assets']; + if (Array.isArray(bindings)) { + if (bindings.length > 128) fail(); + for (const binding of bindings) { + const type = types.includes(binding?.type) ? binding.type : 'OTHER'; + const fields = shape(binding); const signature = JSON.stringify({ type, shape: fields }); + const found = categories.find(item => item.signature === signature); + if (found) found.count++; else categories.push({ signature, type, shape: fields, count: 1 }); + } + } + console.log(JSON.stringify({ status: 'frontend_schema_categories', version_id: '2beea000-cce5-43a5-9255-9724f7318e83', result_id_matches: body.result?.id === '2beea000-cce5-43a5-9255-9724f7318e83', result: shape(body.result), resources: shape(resources), + binding_container: kind(bindings), binding_count: Array.isArray(bindings) ? bindings.length : null, + binding_categories: categories.map(({ signature, ...item }) => item), script: shape(resources?.script), runtime: shape(resources?.script_runtime), + writes: 0, retry: false })); + } catch { + console.error(JSON.stringify({ error: 'frontend_schema_readback_failed', http_status: httpStatus, writes: 0, retry: false })); process.exitCode = 1; + } + JS + + deploy: if: github.event_name == 'workflow_dispatch' && !inputs.read_only_preflight && !inputs.rollback_to_pre_release runs-on: ubuntu-latest diff --git a/tests/console_deploy_safety_validation.mjs b/tests/console_deploy_safety_validation.mjs index 954a88bf..551da5f1 100644 --- a/tests/console_deploy_safety_validation.mjs +++ b/tests/console_deploy_safety_validation.mjs @@ -83,6 +83,31 @@ assert.match(preflight, /environment: runtime-strategy-switch/); assert.doesNotMatch(preflight, /secret put|sync-strategy-profiles|wrangler.*deploy|npm|npx|uses: actions\/checkout|SYNC_TOKEN|WRANGLER_CONFIG/); assert.deepEqual([...preflight.matchAll(/^ ([A-Z_]+):/gm)].map(match => match[1]), ["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID"]); +// The schema diagnostic is read-only, fixed to the same historical version, +// and is gated inside the existing preflight job. Its projection never emits values. +const schemaStep = steps.find(s => s.startsWith("Read fixed frontend baseline schema categories\n")); +assert.ok(schemaStep && preflight.includes("Read fixed frontend baseline schema categories")); +assert.match(schemaStep, /^ if: inputs\.release_frontend_assets$/m); +const schemaScript = schemaStep.match(/node --input-type=module <<'JS'\n([\s\S]*?)\n JS/)[1].split("\n").map(l=>l.slice(10)).join("\n"); +const runSchema = new (Object.getPrototypeOf(async function(){}).constructor)("process","fetch","console",schemaScript); +let schemaCases = 0; +async function schemaCase(payload, expected=true, status=200, env={}) { + const stdout=[],stderr=[],calls=[]; const secret="PRIVATE_SCHEMA_SENTINEL"; + const process={env:{GITHUB_REF:"refs/heads/main",CLOUDFLARE_ACCOUNT_ID:"a".repeat(32),CLOUDFLARE_API_TOKEN:secret,...env},exitCode:0}; + await runSchema(process,async(url,options)=>{calls.push({url,options});return new Response(typeof payload==="string"?payload:JSON.stringify(payload),{status,headers:{"content-type":"application/json"}});},{log:x=>stdout.push(x),error:x=>stderr.push(x)}); + assert.equal(process.exitCode===0,expected);assert.equal(calls.length,env.GITHUB_REF?0:1); + if(calls.length){assert.equal(calls[0].options.method,"GET");assert.equal(calls[0].options.redirect,"error");assert.equal(calls[0].url,`https://api.cloudflare.com/client/v4/accounts/${"a".repeat(32)}/workers/scripts/qsl-strategy-switch-console/versions/2beea000-cce5-43a5-9255-9724f7318e83`);} + assert.ok(!JSON.stringify({stdout,stderr}).includes(secret));assert.ok(!JSON.stringify({stdout,stderr}).includes("a".repeat(32)));schemaCases++; + return stdout.length?JSON.parse(stdout[0]):JSON.parse(stderr[0]); +} +const schemaFixture={success:true,result:{id:"2beea000-cce5-43a5-9255-9724f7318e83",resources:{bindings:[{name:"PRIVATE_SCHEMA_SENTINEL",type:"kv_namespace",namespace_id:"PRIVATE_SCHEMA_SENTINEL"},{name:"OTHER",type:"secret_text",text:"PRIVATE_SCHEMA_SENTINEL"}],script:{etag:"PRIVATE_SCHEMA_SENTINEL",handlers:["fetch"]},script_runtime:{compatibility_date:"2026-06-08",usage_model:"standard"}}}}; +const projected=await schemaCase(schemaFixture);assert.equal(projected.binding_count,2);assert.equal(projected.binding_categories[0].shape.fields.namespace_id,"string"); +const wrongId=structuredClone(schemaFixture);wrongId.result.id="PRIVATE_SCHEMA_SENTINEL";assert.equal((await schemaCase(wrongId)).result_id_matches,false); +const unknownSchema=structuredClone(schemaFixture);unknownSchema.result.resources.PRIVATE_SCHEMA_SENTINEL="PRIVATE_SCHEMA_SENTINEL";unknownSchema.result.resources.bindings[0].type="PRIVATE_SCHEMA_SENTINEL"; +assert.equal((await schemaCase(unknownSchema)).resources.unknown_fields,1); +await schemaCase({...schemaFixture,success:false},false);await schemaCase("{",false);await schemaCase("x".repeat(524289),false); +await schemaCase("PRIVATE_SCHEMA_SENTINEL",false,403);await schemaCase(schemaFixture,false,200,{GITHUB_REF:"refs/heads/other"}); + // Execute the exact selection shell with a fake Git boundary, never checkout the // test repository or evaluate any selected Worker. Real object identities are // separately frozen by the release evidence and the required ancestry check. @@ -512,4 +537,4 @@ const zeroTotal = await failure([{ success: true, result: { deployments: [{ ...d { version_id: versionId, percentage: 0 }, { version_id: deploymentId, percentage: 0 }, ] }] } }]); assert.equal(zeroTotal.checkpoint, "deployment_percentage_total", "all-zero traffic is still invalid"); -console.log(`console_deploy_safety_validation: PASS (manual-only; 16 original write gates; exclusive preflight/deploy/restore; 2 exact Binance gates; ${cases} offline reader cases; ${sourceCases} source cases; ${restoreCases} restore cases; ${overlayCases} overlay cases; ${configCases} config cases; ${releaseCases} isolated release cases)`); +console.log(`console_deploy_safety_validation: PASS (manual-only; 16 original write gates; exclusive preflight/deploy/restore; 2 exact Binance gates; ${cases} offline reader cases; ${sourceCases} source cases; ${restoreCases} restore cases; ${overlayCases} overlay cases; ${configCases} config cases; ${releaseCases} isolated release cases; ${schemaCases} schema readback cases)`);