mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
test(release): validate Linux candidate and packages on isolated runner
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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});});
|
||||
Reference in New Issue
Block a user