Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions .github/workflows/console-qrs-backend-candidate-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions scripts/console_m09_asset_compare.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
32 changes: 27 additions & 5 deletions scripts/console_m09_release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}`),
Expand All @@ -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;
}
Expand Down
61 changes: 61 additions & 0 deletions tests/console_m09_asset_compare.test.mjs
Original file line number Diff line number Diff line change
@@ -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 = "<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('<html>','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']);
});
Loading