diff --git a/.github/workflows/console-qrs-backend-candidate-release.yml b/.github/workflows/console-qrs-backend-candidate-release.yml index 52e7c367..bb553897 100644 --- a/.github/workflows/console-qrs-backend-candidate-release.yml +++ b/.github/workflows/console-qrs-backend-candidate-release.yml @@ -3,9 +3,9 @@ on: workflow_dispatch: inputs: action: - description: Read-only preflight or fixed backend release + description: Read-only preflight, asset comparison, or fixed backend release type: choice - options: [preflight, release] + options: [preflight, asset-compare, release] default: preflight required: true control_sha: @@ -74,6 +74,43 @@ jobs: [[ "$(git rev-parse HEAD)" == "$GITHUB_SHA" ]] [[ "$(git ls-remote origin refs/heads/main | cut -f1)" == "$GITHUB_SHA" ]] node scripts/console_m09_release.mjs preflight + compare-assets: + if: github.repository == 'QuantStrategyLab/QuantRuntimeSettings' && github.ref == 'refs/heads/main' && github.run_attempt == 1 && inputs.action == 'asset-compare' + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: runtime-strategy-switch + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-node@v6 + with: + node-version: '22' + - name: Build the fixed candidate and check parser availability + env: + CONTROL_SHA: ${{ inputs.control_sha }} + WRANGLER_SEND_METRICS: 'false' + CLOUDFLARE_CF_FETCH_ENABLED: 'false' + run: | + set -euo pipefail + [[ "$CONTROL_SHA" == "$GITHUB_SHA" ]] + [[ "$(git rev-parse HEAD)" == "$GITHUB_SHA" ]] + [[ "$(git ls-remote origin refs/heads/main | cut -f1)" == "$GITHUB_SHA" ]] + source="$(node scripts/console_m09_release.mjs asset-candidate-source)" + npm ci --ignore-scripts --prefix web/strategy-switch-console/frontend + npx --yes wrangler@4.106.0 --version + bash scripts/console_m09_offline_bundle.sh "$RUNNER_TEMP/qrs-backend-candidate" "$source" + module="$RUNNER_TEMP/qrs-backend-candidate/worker.js" + node scripts/console_m09_asset_compare.mjs "$module" "$module" + - name: Compare compiled asset declarations using fixed reads + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID || vars.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -euo pipefail + [[ "$(git ls-remote origin refs/heads/main | cut -f1)" == "$GITHUB_SHA" ]] + node scripts/console_m09_release.mjs asset-compare "$RUNNER_TEMP/qrs-backend-candidate/worker.js" provider: if: inputs.action == 'release' needs: prepare diff --git a/scripts/console_m09_asset_compare.mjs b/scripts/console_m09_asset_compare.mjs new file mode 100644 index 00000000..d86edeaf --- /dev/null +++ b/scripts/console_m09_asset_compare.mjs @@ -0,0 +1,66 @@ +// Compare compiled asset declarations without evaluating either Worker. +import {createRequire} from 'node:module'; +import {readFile} from 'node:fs/promises'; +import {pathToFileURL} from 'node:url'; + +const names=['V2_BUILD_ID','V2_PAGE_HTML','V2_ASSETS']; +const require=createRequire(new URL('../web/strategy-switch-console/frontend/package.json',import.meta.url)); + +function declarations(ts,bytes) { + if(!Buffer.isBuffer(bytes)||!bytes.length||bytes.length>10*1024*1024)throw Error('invalid_module'); + const text=new TextDecoder('utf-8',{fatal:true}).decode(bytes); + if(!Buffer.from(text,'utf8').equals(bytes))throw Error('invalid_module'); + const source=ts.createSourceFile('worker.js',text,ts.ScriptTarget.Latest,true,ts.ScriptKind.JS); + if(source.parseDiagnostics.length)throw Error('invalid_module'); + + const result=new Map(); + for(const statement of source.statements) { + if(!ts.isVariableStatement(statement)) { + if(statement.name&&ts.isIdentifier(statement.name)&&names.includes(statement.name.text))throw Error('unsupported_declaration'); + continue; + } + for(const entry of statement.declarationList.declarations) { + if(!ts.isIdentifier(entry.name)||!names.includes(entry.name.text))continue; + const name=entry.name.text; + if(result.has(name)||statement.declarationList.declarations.length!==1||!entry.initializer) + throw Error('ambiguous_declaration'); + const value=entry.initializer; + if(name==='V2_ASSETS') { + if(!ts.isCallExpression(value)||!ts.isPropertyAccessExpression(value.expression)|| + !ts.isIdentifier(value.expression.expression)||value.expression.expression.text!=='Object'|| + value.expression.name.text!=='freeze'||value.arguments.length!==1|| + !ts.isObjectLiteralExpression(value.arguments[0]))throw Error('unsupported_declaration'); + } else if(!ts.isStringLiteral(value)) { + throw Error('unsupported_declaration'); + } + result.set(name,text.slice(statement.getStart(source),statement.end)); + } + } + if(result.size!==names.length)throw Error('missing_declaration'); + return result; +} + +export function compareAssetDeclarations(active,candidate,ts=require('typescript')) { + try { + const before=declarations(ts,active); + const after=declarations(ts,candidate); + const equal=Object.fromEntries(names.map(name=>[name,before.get(name)===after.get(name)])); + const matches=Object.values(equal).every(Boolean); + return {ok:matches,equal,reason:matches?'asset_declarations_equal':'asset_declarations_differ'}; + } catch(error) { + const known=['invalid_module','ambiguous_declaration','unsupported_declaration','missing_declaration']; + return {ok:false,reason:known.includes(error?.message)?error.message:'invalid_module'}; + } +} + +if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href) { + let result={ok:false,reason:'input_unavailable'}; + try { + if(process.argv.length===4) { + const [active,candidate]=await Promise.all(process.argv.slice(2).map(path=>readFile(path))); + result=compareAssetDeclarations(active,candidate); + } + } catch {} + console.log(JSON.stringify(result)); + if(!result.ok)process.exitCode=1; +} diff --git a/scripts/console_m09_release.mjs b/scripts/console_m09_release.mjs index e72e8110..f76b1f9f 100644 --- a/scripts/console_m09_release.mjs +++ b/scripts/console_m09_release.mjs @@ -7,6 +7,7 @@ import {pathToFileURL} from 'node:url'; import {createFixedApi} from './console_946_backend_release.mjs'; import {execute} from './console_full_bundle_release.mjs'; import {RECOVERY_SNAPSHOT} from './console_frontend_candidate_preflight.mjs'; +import {compareAssetDeclarations} from './console_m09_asset_compare.mjs'; export const POLICY = Object.freeze({source:'cabe5a1534e0c992713b084030af313eada758ca', tree:'42b404d5755b658deff73100b495ecf26fa3a6b1', @@ -62,6 +63,24 @@ function readModule(result,id) { throw Error('invalid_module'); return bytes; } +// This bounded check is separate from release and never returns the module or IDs. +export async function inspectActiveAssets(api,candidate,expectedHash=POLICY.candidate) { + if(!Buffer.isBuffer(candidate)||hash(candidate)!==expectedHash) + return {ok:false,reason:'candidate_mismatch'}; + let stage='active_before'; + try { + const prior=activeIdentity(await api.deployments()); + stage='active_module'; + const active=readModule(await api.betaVersion(prior.version),prior.version); + stage='active_after'; + const after=activeIdentity(await api.deployments()); + if(after.deployment!==prior.deployment||after.version!==prior.version) + return {ok:false,reason:'active_drift'}; + return {...compareAssetDeclarations(active,candidate),active_identity_unchanged:true}; + } catch { + return {ok:false,reason:stage}; + } +} function readConfiguration(result,id) { const resources=result?.resources; const runtime=resources?.script_runtime,script=resources?.script; @@ -142,9 +161,11 @@ export async function preflight(api,rollbackTargets={fixed:[],fixedSuccessor:nul } async function main() { const [action,path,...extra]=process.argv.slice(2); - if (extra.length || !['build-source','verify-inputs','preflight','release'].includes(action) || - (['preflight','build-source'].includes(action) ? path!==undefined : !path)) throw Error('arguments'); - if (action==='preflight' ? !sourcePinned(POLICY) : !pinned(POLICY)) throw Error('hold'); + const sourceOnly=['build-source','asset-candidate-source','preflight']; + const readOnly=['asset-candidate-source','asset-compare','preflight'].includes(action); + if (extra.length || ![...sourceOnly,'verify-inputs','asset-compare','release'].includes(action) || + (sourceOnly.includes(action) ? path!==undefined : !path)) throw Error('arguments'); + if (readOnly ? !sourcePinned(POLICY) : !pinned(POLICY)) throw Error('hold'); const git=(...args)=>execFileSync('git',args,{encoding:'utf8'}).trim(); git('merge-base','--is-ancestor',POLICY.source,'HEAD'); const evidence={source:git('rev-parse',`${POLICY.source}^{commit}`),tree:git('rev-parse',`${POLICY.source}^{tree}`), @@ -158,14 +179,15 @@ async function main() { if(!result.ok)process.exitCode=1; return; } - if (action==='build-source') { + if (action==='build-source'||action==='asset-candidate-source') { console.log(POLICY.source); return; } const candidate=await readFile(path); if (hash(candidate)!==POLICY.candidate) throw Error('provenance'); if (action==='verify-inputs') return; - const result=await release({api:createFixedApi(),policy:POLICY,evidence,candidate,release:true}); + const result=action==='asset-compare' ? await inspectActiveAssets(createPreflightApi(),candidate) + : await release({api:createFixedApi(),policy:POLICY,evidence,candidate,release:true}); console.log(JSON.stringify(result)); if(!result.ok)process.exitCode=1; } diff --git a/tests/console_m09_asset_compare.test.mjs b/tests/console_m09_asset_compare.test.mjs new file mode 100644 index 00000000..e6ec3104 --- /dev/null +++ b/tests/console_m09_asset_compare.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {test} from 'node:test'; +import {compareAssetDeclarations} from '../scripts/console_m09_asset_compare.mjs'; + +import {inspectActiveAssets} from '../scripts/console_m09_release.mjs'; + +const assetText='var V2_BUILD_ID = "build";\nvar V2_PAGE_HTML = "";\nvar V2_ASSETS = Object.freeze({});\n'; +const fixed=Buffer.from(assetText); +const compare=text=>compareAssetDeclarations(fixed,Buffer.from(text)); + +test('compares just the three compiled asset statements',()=>{ + assert.deepEqual(compare(assetText+'function unrelated() {}'),{ + ok:true, + equal:{V2_BUILD_ID:true,V2_PAGE_HTML:true,V2_ASSETS:true}, + reason:'asset_declarations_equal', + }); + const mismatch=compare(assetText.replace('','private fixture')); + assert.deepEqual(mismatch.equal,{V2_BUILD_ID:true,V2_PAGE_HTML:false,V2_ASSETS:true}); + assert.equal(mismatch.reason,'asset_declarations_differ'); + assert.equal(JSON.stringify(mismatch).includes('private fixture'),false); +}); + +test('rejects missing, duplicate, unsupported or invalid declarations',()=>{ + assert.equal(compare(assetText.split('\n').slice(1).join('\n')).reason,'missing_declaration'); + assert.equal(compare(assetText+'var V2_BUILD_ID = "duplicate";').reason,'ambiguous_declaration'); + assert.equal(compare(assetText.replace('Object.freeze({})','makeAssets()')).reason,'unsupported_declaration'); + assert.equal(compare(assetText+'var =').reason,'invalid_module'); +}); + +test('binds module comparison to three GET calls and stops on drift',async()=>{ + const version='a'.repeat(8)+'-aaaa-4aaa-8aaa-'+'a'.repeat(12); + const deployment='b'.repeat(8)+'-bbbb-4bbb-8bbb-'+'b'.repeat(12); + const expected=createHash('sha256').update(fixed).digest('hex'); + const calls=[]; + let drift=false; + const api={ + async deployments() { + calls.push('deployments'); + return {deployments:[{id:drift&&calls.length===3?version:deployment,strategy:'percentage', + versions:[{version_id:version,percentage:100}]}]}; + }, + async betaVersion(id) { + calls.push('module'); + assert.equal(id,version); + return {id,modules:[{name:'worker.js',content_type:'application/javascript+module', + content_base64:fixed.toString('base64')}]}; + }, + }; + assert.equal((await inspectActiveAssets(api,fixed,'mismatch')).reason,'candidate_mismatch'); + assert.deepEqual(calls,[]); + const result=await inspectActiveAssets(api,fixed,expected); + assert.equal(result.ok,true); + assert.equal(result.active_identity_unchanged,true); + assert.deepEqual(calls,['deployments','module','deployments']); + assert.doesNotMatch(JSON.stringify(result),/aaaaaaaa|bbbbbbbb/); + calls.length=0; + drift=true; + assert.deepEqual(await inspectActiveAssets(api,fixed,expected),{ok:false,reason:'active_drift'}); + assert.deepEqual(calls,['deployments','module','deployments']); +});