test(release): validate Linux candidate and packages on isolated runner

This commit is contained in:
Forge
2026-09-23 20:34:08 +00:00
parent 61e668e613
commit 36726ada75
4 changed files with 183 additions and 0 deletions
@@ -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
+25
View File
@@ -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)
+27
View File
@@ -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)
+59
View File
@@ -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});});