diff --git a/.github/workflows/release-linux-candidate.yml b/.github/workflows/release-linux-candidate.yml new file mode 100644 index 000000000..68eb43549 --- /dev/null +++ b/.github/workflows/release-linux-candidate.yml @@ -0,0 +1,72 @@ +name: Linux release candidate validation +on: + push: + branches: [candidate/extensions-release-20260923] + paths: + - '.github/workflows/release-linux-candidate.yml' + - 'docs/validation/release-package-proof.cjs' + - 'docs/validation/release-command.py' + - 'docs/validation/release-linux-shards.py' +permissions: + contents: read +jobs: + linux: + if: github.repository == 'colbymchenry/codegraph' + runs-on: ubuntu-24.04 + timeout-minutes: 40 + env: + CODEGRAPH_TELEMETRY: '0' + npm_config_cache: ${{ github.workspace }}/.qa/npm-cache + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '22.23.2' + - run: npm ci --no-audit --no-fund + - name: Build coherent archive + run: python3 docs/validation/release-command.py release-linux-bundle bash scripts/build-bundle.sh linux-x64 v24.16.0 + - name: Assemble npm packages + run: python3 docs/validation/release-command.py release-linux-pack bash scripts/pack-npm.sh + - name: Actual archive and npm CLI, SDK, extension lifecycle and companion + run: python3 docs/validation/release-command.py release-linux-package node docs/validation/release-package-proof.cjs + - name: Full Linux regression with exactly-once coverage + if: success() || failure() + run: python3 docs/validation/release-linux-shards.py "$GITHUB_SHA" + - name: Compiled workers and native paths + if: success() || failure() + run: | + python3 docs/validation/release-command.py release-linux-workers node --test scripts/validation/extensions-runtime.test.cjs + python3 docs/validation/release-command.py release-linux-paths node scripts/validation/native-paths.cjs + - name: Semantic update recovery + if: success() || failure() + env: + SEMANTIC_RECOVERY_OUTPUT: .qa/release-semantic-recovery + run: python3 docs/validation/release-command.py release-linux-semantic node scripts/validation/semantic-update-recovery.cjs + - name: Managed extension recovery + if: success() || failure() + env: + RECOVERY_OUTPUT: .qa/release-managed-recovery + run: python3 docs/validation/release-command.py release-linux-managed node scripts/validation/extensions-recovery.cjs + - name: Managed recovery with custom directory + if: success() || failure() + env: + CODEGRAPH_DIR: .codegraph-release + RECOVERY_OUTPUT: .qa/release-managed-custom + run: python3 docs/validation/release-command.py release-linux-managed-custom node scripts/validation/extensions-recovery.cjs + - name: Retain exact candidate evidence and artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: release-linux-${{ github.sha }}-${{ github.run_attempt }} + include-hidden-files: true + retention-days: 7 + path: | + .qa/recovery/ + .qa/release-package/ + .qa/release-full/ + .qa/release-semantic-recovery/ + .qa/release-managed-recovery/ + .qa/release-managed-custom/ + release/codegraph-linux-x64.tar.gz diff --git a/docs/validation/release-command.py b/docs/validation/release-command.py new file mode 100644 index 000000000..7d3aac1b0 --- /dev/null +++ b/docs/validation/release-command.py @@ -0,0 +1,25 @@ +import subprocess, pathlib, sys, json, datetime, time, re +name, *command = sys.argv[1:] +out = pathlib.Path('.qa/recovery') +out.mkdir(parents=True, exist_ok=True) +start = time.monotonic() +record = {'command': command, 'cwd': str(pathlib.Path.cwd()), 'started_at': datetime.datetime.now(datetime.timezone.utc).isoformat()} +try: + record['revision'] = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() + record['working_tree'] = subprocess.check_output(['git', 'status', '--porcelain=v1'], text=True) +except subprocess.CalledProcessError: + pass +(out / (name + '.started.json')).write_text(json.dumps(record, indent=2)+'\n') +with (out / (name + '.log')).open('w') as log: + result = subprocess.run(command, stdout=log, stderr=subprocess.STDOUT) +record.update(exit_code=result.returncode, seconds=round(time.monotonic()-start, 3), finished_at=datetime.datetime.now(datetime.timezone.utc).isoformat()) +log_text = re.sub(r'\x1b\[[0-9;]*m', '', (out / (name + '.log')).read_text(errors='replace')) +record['summary'] = [line.strip() for line in log_text.splitlines() if re.match(r'^\s*(Test Files|Tests|Duration)\s', line)] +(out / (name + '.json')).write_text(json.dumps(record, indent=2)+'\n') +if name.startswith('final-shard-'): + with pathlib.Path('../RECOVERY-CHECKPOINT.md').open('a') as checkpoint: + checkpoint.write(f"\n- Completed `{name}` at {record['finished_at']}: revision `{record.get('revision')}`, exit {result.returncode}, {record['seconds']} s. Command: `{' '.join(command)}`. Summary: {'; '.join(record['summary'])}. Receipt/log: `codegraph/.qa/recovery/{name}.json` / `.log`.\n") +print(json.dumps(record), flush=True) +if result.returncode: + print('\n'.join((out / (name + '.log')).read_text(errors='replace').splitlines()[-60:]), flush=True) +sys.exit(result.returncode) diff --git a/docs/validation/release-linux-shards.py b/docs/validation/release-linux-shards.py new file mode 100644 index 000000000..7f4788792 --- /dev/null +++ b/docs/validation/release-linux-shards.py @@ -0,0 +1,27 @@ +import sys, subprocess, pathlib, json, datetime, hashlib +root=pathlib.Path(__file__).resolve().parents[2] +expected=sys.argv[1] +assert subprocess.check_output(['git','rev-parse','HEAD'],cwd=root,text=True).strip()==expected +out=root/'.qa/release-full';out.mkdir(parents=True,exist_ok=True) +files=sorted(str(p.relative_to(root)) for p in (root/'__tests__').rglob('*.test.ts')) +manifest={'revision':expected,'files':files,'commands':[]} +(out/'manifest.json').write_text(json.dumps(manifest,indent=2)+'\n') +for shard in range(1,5): + name=f'release-final-shard-{shard}' + if (root/'.qa/recovery'/f'{name}.json').exists(): + receipt=json.loads((root/'.qa/recovery'/f'{name}.json').read_text()) + assert receipt['revision']==expected and receipt['exit_code']==0 + continue + assert subprocess.check_output(['git','rev-parse','HEAD'],cwd=root,text=True).strip()==expected + command=['node','node_modules/vitest/vitest.mjs','run',f'--shard={shard}/4','--maxWorkers=1','--minWorkers=1','--reporter=default','--reporter=json',f'--outputFile.json=.qa/release-full/shard-{shard}.json'] + result=subprocess.run(['python3','docs/validation/release-command.py',name,*command],cwd=root) + if result.returncode:sys.exit(result.returncode) +seen=[];counts={'passed':0,'failed':0,'pending':0} +for shard in range(1,5): + report=json.loads((out/f'shard-{shard}.json').read_text()) + for suite in report['testResults']: + seen.append(str(pathlib.Path(suite['name']).relative_to(root))) + for k in counts:counts[k]+=report[{'passed':'numPassedTests','failed':'numFailedTests','pending':'numPendingTests'}[k]] +assert len(seen)==len(set(seen)) and set(seen)==set(files),(len(seen),len(files),set(files)-set(seen)) +summary={'revision':expected,'counts':counts,'testFiles':len(files),'exactlyOnce':True,'finished':datetime.datetime.now(datetime.timezone.utc).isoformat()} +(out/'summary.json').write_text(json.dumps(summary,indent=2)+'\n');print(json.dumps(summary),flush=True) diff --git a/docs/validation/release-package-proof.cjs b/docs/validation/release-package-proof.cjs new file mode 100644 index 000000000..7ad933b5f --- /dev/null +++ b/docs/validation/release-package-proof.cjs @@ -0,0 +1,59 @@ +// Exact archive and npm-platform entrypoints in a disposable installation. No publication. +const fs=require('node:fs'),path=require('node:path'),os=require('node:os'),cp=require('node:child_process'),assert=require('node:assert/strict'),crypto=require('node:crypto'),{createRequire}=require('node:module'); +const repo=path.resolve(__dirname,'../..'),out=path.resolve(process.env.PACKAGE_PROOF_OUTPUT||path.join(repo,'.qa/release-package'));fs.mkdirSync(out,{recursive:true}); +const lab=fs.mkdtempSync(path.join(os.tmpdir(),'cg-release-package-')); +const env={...process.env,CODEGRAPH_TELEMETRY:'0',CODEGRAPH_NO_DAEMON:'1',CODEGRAPH_NO_DOWNLOAD:'1',npm_config_cache:process.env.npm_config_cache||path.join(repo,'.qa/npm-cache'),CODEGRAPH_PARSE_WORKERS:'2',CODEGRAPH_RESOLVE_WORKERS:'2'}; +const rows=[],commands=[];let companion; +const save=()=>fs.writeFileSync(path.join(out,'result.json'),JSON.stringify({revision:cp.execFileSync('git',['rev-parse','HEAD'],{cwd:repo,encoding:'utf8'}).trim(),rows,commands},null,2)); +async function run(command,args,cwd=lab,expected=0){const started=Date.now();const r=await new Promise(resolve=>{const child=cp.spawn(command,args,{cwd,env,timeout:args.includes('pack')?600000:180000});let stdout='',stderr='',error;child.stdout.on('data',b=>stdout+=b);child.stderr.on('data',b=>stderr+=b);child.on('error',e=>error=String(e));child.on('close',(exit,signal)=>resolve({stdout,stderr,error,exit,signal}));});commands.push({command:[command,...args],cwd,expected,exit:r.exit,signal:r.signal,error:r.error,ms:Date.now()-started});fs.writeFileSync(path.join(out,`command-${commands.length}.log`),r.stdout+r.stderr);save();assert.equal(r.exit,expected,r.stdout+r.stderr+r.error);return r.stdout;} +async function check(name,f){await f();rows.push({name,pass:true});save();console.log('PASS',name);} +const hash=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); +(async()=>{ + const archive=path.join(repo,'release/codegraph-linux-x64.tar.gz'); + await run('tar',['-xzf',archive,'-C',lab]);const bundle=path.join(lab,'codegraph-linux-x64'),bin=path.join(bundle,'bin/codegraph'); + await check('archive has retained viewer, schemas and grammars',async()=>{await run(process.execPath,[path.join(repo,'scripts/check-ui-build.mjs'),'--root',path.join(bundle,'lib')]);assert(fs.existsSync(path.join(bundle,'lib/dist/db/schema.sql')));}); + const main=path.join(repo,'release/npm/main'),platform=path.join(repo,'release/npm/codegraph-linux-x64'); + const {npmCommand}=require(path.join(repo,'scripts/validation/platform-tools.cjs')); + const mp=JSON.parse(await run(...npmCommand(['pack','--ignore-scripts','--json','--pack-destination',out]),main))[0]; + const pp=JSON.parse(await run(...npmCommand(['pack','--ignore-scripts','--json','--pack-destination',out]),platform))[0]; + const install=path.join(lab,'installed');fs.mkdirSync(install);fs.writeFileSync(path.join(install,'package.json'),'{"private":true}'); + await run(...npmCommand(['install','--ignore-scripts','--no-audit','--no-fund','--omit=optional',path.join(out,mp.filename),path.join(out,pp.filename)]),install); + const shim=path.join(install,'node_modules/@colbymchenry/codegraph/npm-shim.js'); + const requireInstalled=createRequire(path.join(install,'package.json')),sdk=requireInstalled('@colbymchenry/codegraph'); + for(const [label,command,prefix] of [['archive',bin,[]],['npm-platform',process.execPath,[shim]]]) await check(label+' version/help and both deferred aliases',async()=>{ + assert.equal((await run(command,[...prefix,'--version'])).trim(),'1.6.0');const help=await run(command,[...prefix,'--help']);assert(help.includes('extensions'));assert(!/^\s+(ui|web)[\s[]/m.test(help)); + for(const alias of ['ui','web']){await run(command,[...prefix,alias],lab,1);await run(command,[...prefix,alias,'--help'],lab,1);await run(command,[...prefix,'help',alias],lab,1);} + }); + const project=path.join(lab,'parent/project'),author=path.join(lab,'author');fs.mkdirSync(project,{recursive:true}); + const cli=(args,expected=0,cwd=lab)=>run(process.execPath,[shim,...args],cwd,expected); + await check('packed CLI ignores schema-less ancestor and does not initialize child',async()=>{ + const p=path.join(lab,'parent/.codegraph');fs.mkdirSync(p);fs.writeFileSync(path.join(p,'codegraph.db'),''); + const output=await cli(['status'],0,project);assert(/not initialized/i.test(output));assert(!/no such table|SQLITE_ERROR/.test(output));assert(!fs.existsSync(path.join(project,'.codegraph/codegraph.db'))); + }); + fs.writeFileSync(path.join(project,'checkout.events.yaml'),'order.created: send_receipt\n');fs.writeFileSync(path.join(project,'handlers.py'),'def send_receipt():\n return "sent"\n'); + const artifact=path.join(lab,'first.cgext'); + await check('public packed SDK and author CLI',async()=>{ + assert.equal(sdk.EXTENSION_API_VERSION,1);assert.equal(typeof sdk.packExtension,'function'); + await cli(['extensions','create',author,'--id','release-events']);await cli(['extensions','test',author]);await cli(['extensions','pack',author,'--out',artifact]); + }); + async function links(){const cg=await sdk.CodeGraph.open(project);try{return cg.getNodesByKind('route').flatMap(n=>cg.getOutgoingEdges(n.id).filter(e=>e.metadata?.synthesizedBy==='release-events').map(e=>({source:n.name,target:cg.getNode(e.target).name,label:e.metadata.label})));}finally{cg.close();}} + const expected=label=>[{source:'event:order.created',target:'send_receipt',label}]; + await check('packed install produces actual graph',async()=>{await cli(['extensions','install',artifact,'--path',project]);assert.deepEqual(await links(),expected('Python event dispatch'));}); + await check('packed update changes actual graph',async()=>{ + const pkg=path.join(author,'package.json'),manifest=JSON.parse(fs.readFileSync(pkg,'utf8'));manifest.version='0.1.1';fs.writeFileSync(pkg,JSON.stringify(manifest)); + const source=path.join(author,'index.cjs');fs.writeFileSync(source,fs.readFileSync(source,'utf8').replace('Python event dispatch','Updated event dispatch')); + await cli(['extensions','pack',author,'--out',artifact]);await cli(['extensions','update',artifact,'--path',project]);assert.deepEqual(await links(),expected('Updated event dispatch')); + }); + await check('failed packed update preserves config and graph',async()=>{ + const config=fs.readFileSync(path.join(project,'codegraph.json'),'utf8'),pkg=path.join(author,'package.json'),manifest=JSON.parse(fs.readFileSync(pkg,'utf8'));manifest.version='0.1.2';fs.writeFileSync(pkg,JSON.stringify(manifest));fs.writeFileSync(path.join(author,'index.cjs'),"module.exports=()=>{throw Error('bounded failed update')};"); + await cli(['extensions','pack',author,'--out',artifact]);await cli(['extensions','update',artifact,'--path',project],1);assert.equal(fs.readFileSync(path.join(project,'codegraph.json'),'utf8'),config);assert.deepEqual(await links(),expected('Updated event dispatch')); + }); + await check('packed disable enable remove',async()=>{await cli(['extensions','disable','release-events','--path',project]);assert.deepEqual(await links(),[]);await cli(['extensions','enable','release-events','--path',project]);assert.deepEqual(await links(),expected('Updated event dispatch'));await cli(['extensions','remove','release-events','--path',project]);assert.deepEqual(await links(),[]);}); + await check('real packed extensions connect still serves companion',async()=>{ + companion=cp.spawn(bin,['extensions','connect',project,'--marketplace','https://example.invalid','--port','0'],{cwd:lab,env});let output='';let errors='';companion.stderr.on('data',b=>errors+=b);let url; + await new Promise((resolve,reject)=>{const timeout=setTimeout(()=>reject(Error('companion startup timeout '+errors)),20000);companion.stdout.on('data',b=>{output+=b;url=output.match(/http:\/\/127\.0\.0\.1:[0-9]+\S*/)?.[0];if(url){clearTimeout(timeout);resolve();}});companion.on('error',reject);}); + const response=await fetch(url);assert.equal(response.status,200);const body=await response.text();assert(body.includes('connect'));rows.push({name:'companion-page',pass:true,status:response.status,bodySha256:crypto.createHash('sha256').update(body).digest('hex')});companion.kill('SIGTERM');await new Promise(resolve=>companion.once('exit',resolve));companion=null; + }); + fs.writeFileSync(path.join(out,'artifacts.json'),JSON.stringify({revision:cp.execFileSync('git',['rev-parse','HEAD'],{cwd:repo,encoding:'utf8'}).trim(),node:await run(path.join(bundle,'node'),['--version']),archive:{path:archive,sha256:hash(archive)},main:{path:mp.filename,sha256:hash(path.join(out,mp.filename)),files:mp.files},platform:{path:pp.filename,sha256:hash(path.join(out,pp.filename)),files:pp.files}},null,2)); + console.log(JSON.stringify({rows:rows.length,commands:commands.length,success:true})); +})().catch(e=>{rows.push({name:'failure',pass:false,error:String(e.stack)});save();console.error(e);process.exitCode=1;}).finally(()=>{companion?.kill('SIGTERM');fs.rmSync(lab,{recursive:true,force:true,maxRetries:10,retryDelay:100});});