From bca5ff69488965b5400c5830b272483524150901 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:36:15 +0800 Subject: [PATCH] fix(console): retain validated named exports in release comparison Co-Authored-By: Codex --- .../deploy-strategy-switch-console.yml | 22 ++++++++++++++++--- tests/console_deploy_safety_validation.mjs | 14 ++++++++---- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy-strategy-switch-console.yml b/.github/workflows/deploy-strategy-switch-console.yml index 86efa0db..0f3f82a1 100644 --- a/.github/workflows/deploy-strategy-switch-console.yml +++ b/.github/workflows/deploy-strategy-switch-console.yml @@ -231,7 +231,7 @@ jobs: 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 keys = ['resources','metadata','number','annotations','startup_time_ms','bindings','script','script_runtime','etag','handlers','named_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(); @@ -730,12 +730,26 @@ jobs: if (!value || Object.keys(value).some(key => !['bindings', 'script', 'script_runtime'].includes(key)) || !Array.isArray(value.bindings) || !value.bindings.length || value.bindings.length > 128 || !runtime || !script || Object.keys(runtime).some(key => !['compatibility_date', 'compatibility_flags', 'usage_model', 'limits', 'migration_tag', 'exports'].includes(key)) - || Object.keys(script).some(key => !['etag', 'handlers', 'last_deployed_from', 'placement_mode'].includes(key)) + || Object.keys(script).some(key => !['etag', 'handlers', 'named_handlers', 'last_deployed_from', 'placement_mode'].includes(key)) || typeof script.etag !== 'string' || !script.etag || script.etag.length > 256 || !(script.handlers === null || (Array.isArray(script.handlers) && script.handlers.every(x => typeof x === 'string'))) || !/^\d{4}-\d{2}-\d{2}$/.test(runtime.compatibility_date ?? '') || (runtime.compatibility_flags !== undefined && (!Array.isArray(runtime.compatibility_flags) || runtime.compatibility_flags.some(x => typeof x !== 'string'))) || !['standard', 'bundled', 'unbound'].includes(runtime.usage_model)) fail(); + // Current API also reports named exports (including DO classes). + // Validate their identity/handler shape and compare them below; do + // not discard this field merely to accept a newer response schema. + if (script.named_handlers !== undefined) { + if (!Array.isArray(script.named_handlers) || script.named_handlers.length > 128) fail(); + const exports = new Set(); + for (const item of script.named_handlers) { + if (!item || Object.keys(item).some(key => !['name', 'handlers'].includes(key)) + || typeof item.name !== 'string' || !/^[A-Za-z_$][A-Za-z0-9_$]{0,127}$/.test(item.name) || exports.has(item.name) + || (item.handlers !== undefined && (!Array.isArray(item.handlers) || item.handlers.length > 32 + || item.handlers.some(handler => typeof handler !== 'string' || !/^[A-Za-z_$][A-Za-z0-9_$]{0,127}$/.test(handler))))) fail(); + exports.add(item.name); + } + } const names = new Set(); for (const binding of value.bindings) { if (!binding || !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(binding.name ?? '') || names.has(binding.name) @@ -776,7 +790,9 @@ jobs: if (uploaded === prior) fail(); stage = 'uploaded_configuration'; const next = resources(await request(`/versions/${uploaded}`), uploaded); const comparable = value => ({ bindings: [...value.bindings].sort((a, b) => a.name.localeCompare(b.name)), - script_runtime: value.script_runtime, handlers: value.script.handlers, placement_mode: value.script.placement_mode ?? null }); + script_runtime: value.script_runtime, handlers: value.script.handlers, + named_handlers: value.script.named_handlers === undefined ? null : [...value.script.named_handlers].sort((a,b) => a.name.localeCompare(b.name)), + placement_mode: value.script.placement_mode ?? null }); if (canonical(comparable(next)) !== canonical(comparable(old)) || next.script.etag === old.script.etag) fail(); // Same-Worker workflow concurrency serializes our release controls. These fresh // GETs detect observed external drift; Cloudflare offers no compare-and-swap diff --git a/tests/console_deploy_safety_validation.mjs b/tests/console_deploy_safety_validation.mjs index 551da5f1..03deeb94 100644 --- a/tests/console_deploy_safety_validation.mjs +++ b/tests/console_deploy_safety_validation.mjs @@ -100,8 +100,8 @@ async function schemaCase(payload, expected=true, status=200, env={}) { 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 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"],named_handlers:[{name:"PRIVATE_SCHEMA_SENTINEL",handlers:[]}]},script_runtime:{compatibility_date:"2026-06-08",usage_model:"standard"}}}}; +const projected=await schemaCase(schemaFixture);assert.equal(projected.script.fields.named_handlers,"array");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); @@ -272,7 +272,7 @@ const remoteBindings=[{name:"STRATEGY_SWITCH_CONFIG",type:"kv_namespace",namespa {name:"STRATEGY_SWITCH_RUNTIME_INSTANCES",type:"durable_object_namespace",namespace_id:"c".repeat(32),class_name:"RuntimeInstances"}, {name:"FLAG",type:"plain_text",text:privateRelease},{name:"DATA",type:"json",json:{private:privateRelease}},{name:"SECRET",type:"secret_text"}]; const runtime={compatibility_date:"2026-06-08",compatibility_flags:[],usage_model:"standard",limits:{},migration_tag:"runtime-instances-v1",exports:{RuntimeInstances:{type:"durable-object",storage:"sqlite"}}}; -const resource=etag=>({bindings:structuredClone(remoteBindings),script_runtime:structuredClone(runtime),script:{etag,handlers:["fetch","scheduled"],last_deployed_from:"api"}}); +const resource=etag=>({bindings:structuredClone(remoteBindings),script_runtime:structuredClone(runtime),script:{etag,handlers:["fetch","scheduled"],named_handlers:[{name:"RuntimeInstances",handlers:[]}],last_deployed_from:"api"}}); const wrap=result=>({success:true,errors:[],result}), dep=(id,v)=>({id,strategy:"percentage",versions:[{version_id:v,percentage:100}]}); const responseSequence=()=>[wrap({deployments:[dep(priorDeployment,priorVersion)]}),wrap({id:priorVersion,resources:resource("old-code")}), wrap({items:[{id:priorVersion}]}),wrap({id:newVersion}),wrap({id:newVersion,resources:resource("new-code")}), @@ -311,11 +311,17 @@ for(const [at,change] of [ [1,x=>{x.result.resources.bindings[0].namespace_id={private:privateRelease};}], [1,x=>{delete x.result.resources.bindings[2].text;}],[1,x=>{delete x.result.resources.bindings[3].json;}], [1,x=>{x.result.resources.bindings[4].text=privateRelease;}], + [1,x=>{x.result.resources.script.named_handlers={};}], + [1,x=>{delete x.result.resources.script.named_handlers[0].name;}], + [1,x=>{x.result.resources.script.named_handlers[0].handlers="PRIVATE_RELEASE_SENTINEL";}], + [1,x=>{x.result.resources.script.named_handlers.push(x.result.resources.script.named_handlers[0]);}], [1,x=>{x.result.resources.script_runtime.unknown=privateRelease;}],[1,x=>{x.result.resources.bindings[0].type="service";}],[1,x=>{x.result.resources.assets={};}], [2,x=>{x.result.items[0].id=newVersion;}],[3,x=>{x.result.id="untrusted";}], [4,x=>{x.result.resources.bindings[0].namespace_id="e".repeat(32);}],[4,x=>{x.result.resources.bindings.pop();}],[4,x=>{x.result.resources.bindings[2].text="different";}], [4,x=>{x.result.resources.script_runtime.compatibility_date="2026-06-09";}],[4,x=>{x.result.resources.script_runtime.limits={cpu_ms:1};}], - [4,x=>{x.result.resources.script_runtime.migration_tag="changed";}],[4,x=>{x.result.resources.script.handlers=["fetch"];}],[4,x=>{x.result.resources.script.etag="old-code";}], + [4,x=>{x.result.resources.script_runtime.migration_tag="changed";}],[4,x=>{x.result.resources.script.named_handlers[0].name="ChangedExport";}], + [4,x=>{x.result.resources.script.named_handlers[0].handlers=["alarm"];}], + [4,x=>{x.result.resources.script.handlers=["fetch"];}],[4,x=>{x.result.resources.script.etag="old-code";}], [5,x=>{x.result.deployments[0].versions[0].version_id=newVersion;}],[6,x=>{x.result.items[0].id=priorVersion;}],[7,x=>{x.result.versions[0].percentage=99;}],[8,x=>{x.result.deployments[0].id=priorDeployment;}] ])await releaseCase({mutate:r=>change(r[at])},false,at+1); for (const corrupt of [b=>{delete b.namespace_id;},b=>{b.namespace_id={private:privateRelease};}]) {