Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a14bfc-9643-7872-aa12-98326dcf25e7
240 lines
15 KiB
Python
240 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Build-only public Forgejo owner recipe; no deployment or publication code."""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import tarfile
|
|
import time
|
|
import math
|
|
import datetime
|
|
|
|
OWNER=Path(__file__).resolve().parents[1]
|
|
INPUTS=OWNER/'build/forgejo'
|
|
LOCK=json.loads((INPUTS/'inputs.lock.json').read_text())
|
|
|
|
def digest(path):
|
|
h=hashlib.sha256()
|
|
with Path(path).open('rb') as f:
|
|
for block in iter(lambda:f.read(1024*1024),b''):h.update(block)
|
|
return h.hexdigest()
|
|
|
|
def save(path,data):
|
|
Path(path).write_text(json.dumps(data,indent=2,sort_keys=True)+'\n')
|
|
|
|
def check(path,expected):
|
|
if digest(path)!=expected:raise ValueError('frozen public input digest differs: '+str(path))
|
|
|
|
def base_env(work):
|
|
return {'PATH':str(work/'node-v22.15.0-linux-x64/bin')+':'+str(work/'go/bin')+':/usr/bin:/bin',
|
|
'HOME':str(work/'home'),'GOTOOLCHAIN':'local','GOPROXY':'off','GOSUMDB':'off','GOVCS':'*:off',
|
|
'GOPATH':str(work/'gopath'),'GOMODCACHE':json.loads((work/'prepared.json').read_text())['module_cache'],
|
|
'GOCACHE':str(work/'gocache'),'CGO_ENABLED':'1','GOMAXPROCS':'4',
|
|
'GOFLAGS':'-trimpath -buildvcs=false -p=4','SOURCE_DATE_EPOCH':str(LOCK['source_date_epoch']),
|
|
'TZ':'UTC','LC_ALL':'C','NODE_OPTIONS':'--max-old-space-size=4096',
|
|
'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null','GIT_TERMINAL_PROMPT':'0',
|
|
'NPM_CONFIG_USERCONFIG':str(work/'empty.npmrc'),'NPM_CONFIG_GLOBALCONFIG':str(work/'empty-global.npmrc'),
|
|
'NPM_CONFIG_CACHE':str(work/'npm-cache'),'NPM_CONFIG_REGISTRY':'https://registry.npmjs.org',
|
|
'NPM_CONFIG_OFFLINE':'true','NPM_CONFIG_AUDIT':'false','NPM_CONFIG_FUND':'false',
|
|
'NPM_CONFIG_UPDATE_NOTIFIER':'false','PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD':'1'}
|
|
|
|
def sandbox(work,env):
|
|
cache=Path(env['GOMODCACHE'])
|
|
return ['bwrap','--unshare-net','--unshare-pid','--ro-bind','/usr','/usr','--ro-bind','/lib','/lib',
|
|
'--ro-bind','/lib64','/lib64','--symlink','usr/bin','/bin','--proc','/proc','--dev','/dev',
|
|
'--tmpfs','/tmp','--bind',str(work),str(work),'--ro-bind',str(cache),str(cache),
|
|
'--chdir',str(work/'source'),'--','env','-i']+[k+'='+v for k,v in env.items()]
|
|
|
|
def run(work,name,args,env=None,offline=False,cwd=None):
|
|
actual=sandbox(work,env)+args if offline else args
|
|
start=time.time()
|
|
with (work/(name+'.log')).open('w') as out:
|
|
proc=subprocess.Popen(actual,cwd=cwd or work,env={"PATH":"/usr/bin:/bin","HOME":str(work/"home")} if offline else env,stdout=out,stderr=subprocess.STDOUT)
|
|
save(work/'process.json',{'phase':name,'pid':proc.pid,'driver_pid':os.getpid(),'argv':args,'state':'running'})
|
|
code=proc.wait()
|
|
result={'phase':name,'argv':args,'returncode':code,'seconds':round(time.time()-start,2)}
|
|
save(work/(name+'-result.json'),result);save(work/'process.json',dict(result,state='complete',pid=None,driver_pid=None))
|
|
print(json.dumps(result),flush=True)
|
|
if code:raise RuntimeError('phase failed; retained log: '+str(work/(name+'.log')))
|
|
return result
|
|
|
|
def checked_inputs(work):
|
|
for n,h in LOCK['host_tools'].items():check(n,h)
|
|
for n,h in LOCK['lockfiles'].items():check(work/'source'/n,h)
|
|
for x in LOCK['reviewed_outputs']:check(work/'source'/x['path'],x['sha256'])
|
|
|
|
def prepare(a,work):
|
|
if work.exists():raise ValueError('workspace must be new; existing trial is never restarted or overwritten')
|
|
for n,key in [('source_archive','source_archive_sha256'),('go_archive','go_archive_sha256'),('node_archive','node_archive_sha256')]:
|
|
if not getattr(a,n):raise ValueError('explicit '+n+' required')
|
|
check(getattr(a,n),LOCK[key])
|
|
if not a.module_cache:raise ValueError('explicit public module cache required')
|
|
cache=Path(a.module_cache).resolve();assert cache.is_dir()
|
|
check(INPUTS/LOCK['patch_file'],LOCK['patch_sha256'])
|
|
for n,h in LOCK['host_tools'].items():check(n,h)
|
|
work.mkdir(parents=True);(work/'home').mkdir()
|
|
with tarfile.open(a.source_archive) as t:
|
|
# The official archive is digest-bound before any member is extracted.
|
|
parent=work/'unpacked';parent.mkdir();t.extractall(parent,filter='data')
|
|
folders=list(parent.iterdir());assert len(folders)==1 and folders[0].is_dir();folders[0].rename(work/'source');parent.rmdir()
|
|
for n in ['go_archive','node_archive']:
|
|
with tarfile.open(getattr(a,n)) as t:t.extractall(work,filter='data')
|
|
for n in ['empty.npmrc','empty-global.npmrc']:(work/n).write_text('')
|
|
if a.go_cache_seed:shutil.copytree(Path(a.go_cache_seed).resolve(),work/'gocache')
|
|
if a.npm_cache_seed:shutil.copytree(Path(a.npm_cache_seed).resolve(),work/'npm-cache')
|
|
subprocess.run(['/usr/bin/git','apply','--check',str(INPUTS/LOCK['patch_file'])],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'})
|
|
subprocess.run(['/usr/bin/git','apply',str(INPUTS/LOCK['patch_file'])],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'})
|
|
save(work/'prepared.json',{'module_cache':str(cache),'lock_sha256':digest(INPUTS/'inputs.lock.json'),'public_archives_verified':True})
|
|
checked_inputs(work)
|
|
packages=json.loads((work/'source/package-lock.json').read_text())['packages']
|
|
for name,package in packages.items():
|
|
if not name:continue
|
|
if not package.get('resolved','').startswith('https://registry.npmjs.org/') or not package.get('integrity','').startswith('sha512-'):
|
|
raise ValueError('non-public/non-integrity-bound npm package: '+name)
|
|
print(json.dumps({'prepared':True,'lockfile_npm_packages':len(packages)-1}))
|
|
|
|
def normalize(folder):
|
|
epoch=LOCK['source_date_epoch']
|
|
for root,dirs,files in os.walk(folder):
|
|
dirs[:]=[x for x in dirs if x not in ('node_modules','.git')]
|
|
for n in files:
|
|
p=Path(root)/n
|
|
if not p.is_symlink():os.utime(p,(epoch,epoch))
|
|
os.utime(root,(epoch,epoch))
|
|
|
|
def build(work):
|
|
checked_inputs(work);env=base_env(work)
|
|
run(work,'verify-go-modules',['go','mod','verify'],env,True)
|
|
run(work,'npm-offline-ci',['npm','ci','--offline','--ignore-scripts','--no-audit','--no-fund'],env,True)
|
|
normalize(work/'source')
|
|
version=['GOFLAGS=-trimpath -buildvcs=false -p=4','RELEASE_VERSION='+LOCK['release_version'],'FORGEJO_VERSION='+LOCK['release_version'],'FORGEJO_VERSION_API='+LOCK['release_version']]
|
|
run(work,'frontend',['make']+version+['frontend'],env,True)
|
|
for n in ['public','options','templates','modules/migration/schemas']:normalize(work/'source'/n)
|
|
run(work,'embedded-and-static-backend',['make']+version+['TAGS='+LOCK['tags'],'go-check','generate-backend','static-executable'],env,True)
|
|
checked_inputs(work)
|
|
save(work/'build.json',{'binary_sha256':digest(work/'source/gitea'),'source_locks_preserved':True,'frontend_built':True,'tags':LOCK['tags'],'image_deployment_selected':False})
|
|
|
|
def verify(work):
|
|
checked_inputs(work);binary=work/'source/gitea'
|
|
target=work/'embedded-extracted'
|
|
if target.exists():raise ValueError('retain prior verification; extraction target already exists')
|
|
target.mkdir()
|
|
# No source, asset tree, host /usr, libraries, configuration or HOME is
|
|
# exposed to the executable. A filesystem fallback cannot pass this proof.
|
|
bare=['bwrap','--unshare-net','--unshare-pid','--proc','/proc','--dev','/dev',
|
|
'--tmpfs','/tmp','--dir','/app','--ro-bind',str(binary),'/app/forgejo',
|
|
'--bind',str(target),'/results','--chdir','/tmp','--clearenv']
|
|
for k,v in {'PATH':'/nonexistent','HOME':'/tmp/home','USER':'builder','USERNAME':'builder',
|
|
'GITEA_WORK_DIR':'/tmp/work','GITEA_CUSTOM':'/tmp/custom',
|
|
'GITEA_APP_INI':'/tmp/absent-app.ini','TZ':'UTC','LC_ALL':'C'}.items():
|
|
bare+=['--setenv',k,v]
|
|
bare+=['--','/app/forgejo']
|
|
outer={'PATH':'/usr/bin:/bin','HOME':str(work/'home')}
|
|
run(work,'binary-version',bare+['--version'],outer)
|
|
run(work,'new-cli-help',bare+['admin','auth','add-oauth','--help'],outer)
|
|
assert '--required-claim-values' in (work/'new-cli-help.log').read_text()
|
|
assert '--secret-file' in (work/'new-cli-help.log').read_text()
|
|
run(work,'embedded-extract',bare+['embedded','extract','--include-vendored','--destination','/results','**'],outer)
|
|
records={}
|
|
for folder in ['public','options','templates']:
|
|
for f in sorted((work/'source'/folder).rglob('*')):
|
|
if not f.is_file():continue
|
|
n=str(f.relative_to(work/'source'));q=target/n
|
|
if not q.is_file() or digest(f)!=digest(q):raise ValueError('embedded asset missing/different: '+n)
|
|
records[n]=digest(f)
|
|
assert 'public/assets/js/index.js' in records and 'public/assets/css/index.css' in records
|
|
assert 'public/assets/licenses.txt' in records
|
|
save(work/'embedded-files.json',records)
|
|
readelf=subprocess.run(['/usr/bin/readelf','-l',str(binary)],capture_output=True,text=True,check=True,env={'PATH':'/usr/bin:/bin'})
|
|
assert 'INTERP' not in readelf.stdout
|
|
save(work/'verification.json',{'binary_sha256':digest(binary),'embedded_files':len(records),
|
|
'embedded_file_manifest_sha256':digest(work/'embedded-files.json'),'all_public_options_templates_exact':True,
|
|
'no_source_assets_libraries_or_host_config_mounted':True,
|
|
'no_dynamic_interpreter':True,'new_cli_flag_present':True,'network':'bwrap --unshare-net',
|
|
'source_locks_preserved':True,'architecture_approval':False,'native_admission':False})
|
|
print((work/'verification.json').read_text())
|
|
|
|
def container(work):
|
|
verified=json.loads((work/'verification.json').read_text());check(work/'source/gitea',verified['binary_sha256'])
|
|
context=work/'container-context'
|
|
if context.exists():raise ValueError('container trial already exists; retain evidence before an explicit revised trial')
|
|
archive_path=work/'image.oci.tar'
|
|
if archive_path.exists():raise ValueError('retain existing OCI artifact; never overwrite it')
|
|
base_bytes=(INPUTS/'runtime-base-config.json').read_bytes()
|
|
assert hashlib.sha256(base_bytes).hexdigest()==LOCK['runtime_base_config_sha256']
|
|
original=json.loads(base_bytes)
|
|
epoch=max(LOCK['source_date_epoch'],math.ceil(datetime.datetime.fromisoformat(original['created'].replace('Z','+00:00')).timestamp()))
|
|
context.mkdir();shutil.copyfile(work/'source/gitea',context/'gitea')
|
|
os.utime(context/'gitea',(LOCK['source_date_epoch'],)*2)
|
|
(context/'Dockerfile').write_text('FROM '+LOCK['runtime_base']+'\nARG SOURCE_DATE_EPOCH\nLABEL org.opencontainers.image.version="'+LOCK['release_version']+'" org.opencontainers.image.revision="'+LOCK['source_commit']+'" org.railiance.source.patch.sha256="'+LOCK['patch_sha256']+'"\nCOPY --chown=0:0 --chmod=755 gitea /app/gitea/gitea\n')
|
|
os.utime(context/'Dockerfile',(LOCK['source_date_epoch'],)*2)
|
|
config=work/'docker-config';config.mkdir(exist_ok=True)
|
|
env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'DOCKER_CONFIG':str(config),'DOCKER_HOST':'unix:///var/run/docker.sock'}
|
|
# SOURCE_DATE_EPOCH alone does not normalise COPY parent-directory mtimes.
|
|
# OCI output permits layer rewriting without the Docker-store unpack conflict.
|
|
# The epoch never predates the immutable base, preserving its exact layer prefix.
|
|
run(work,'container-build',['docker','buildx','build','--output','type=oci,dest='+str(archive_path)+',rewrite-timestamp=true',
|
|
'--no-cache','--platform',LOCK['platform'],'--pull=false','--network=none','--provenance=false','--sbom=false',
|
|
'--metadata-file',str(work/'image-build-metadata.json'),'--build-arg','SOURCE_DATE_EPOCH='+str(epoch),str(context)],env)
|
|
metadata=json.loads((work/'image-build-metadata.json').read_text())
|
|
with tarfile.open(archive_path) as archive:
|
|
index_bytes=archive.extractfile('index.json').read();index=json.loads(index_bytes)
|
|
assert len(index['manifests'])==1
|
|
descriptor=index['manifests'][0]
|
|
assert descriptor['platform']=={'architecture':'amd64','os':'linux'}
|
|
assert descriptor['digest']==metadata['containerimage.digest']
|
|
def blob(d):
|
|
value=archive.extractfile('blobs/sha256/'+d.split(':')[1]).read()
|
|
assert 'sha256:'+hashlib.sha256(value).hexdigest()==d
|
|
return value
|
|
manifest=json.loads(blob(descriptor['digest']))
|
|
config_digest=manifest['config']['digest'];config=json.loads(blob(config_digest))
|
|
assert config_digest==metadata['containerimage.config.digest']
|
|
for layer in manifest['layers']:blob(layer['digest'])
|
|
assert config['rootfs']['diff_ids'][:-1]==original['rootfs']['diff_ids']
|
|
assert config['architecture']=='amd64' and config['os']=='linux'
|
|
for key in ['User','Entrypoint','Cmd','Env','WorkingDir','Volumes']:
|
|
assert config['config'].get(key)==original['config'].get(key),key
|
|
labels=dict(original['config'].get('Labels',{}))
|
|
labels.update({'org.opencontainers.image.version':LOCK['release_version'],
|
|
'org.opencontainers.image.revision':LOCK['source_commit'],'org.railiance.source.patch.sha256':LOCK['patch_sha256']})
|
|
assert config['config'].get('Labels')==labels
|
|
assert config['config']['User']=='1000:1000'
|
|
expected_created=datetime.datetime.fromtimestamp(epoch,datetime.timezone.utc).isoformat(timespec='seconds').replace('+00:00','Z')
|
|
assert config['created']==expected_created
|
|
found=False
|
|
last='blobs/sha256/'+manifest['layers'][-1]['digest'].split(':')[1]
|
|
with tarfile.open(fileobj=archive.extractfile(last),mode='r|*') as layer:
|
|
for member in layer:
|
|
name=member.name.lstrip('./');assert name in ('app','app/gitea','app/gitea/gitea')
|
|
if name=='app/gitea/gitea':
|
|
h=hashlib.sha256();stream=layer.extractfile(member)
|
|
for block in iter(lambda:stream.read(1024*1024),b''):h.update(block)
|
|
assert h.hexdigest()==verified['binary_sha256'];found=True
|
|
assert found
|
|
save(work/'container-result.json',{'image_manifest_digest':descriptor['digest'],'config_digest':config_digest,
|
|
'oci_index_sha256':hashlib.sha256(index_bytes).hexdigest(),'archive_sha256':digest(archive_path),
|
|
'artifact_path':str(archive_path),'source_epoch':LOCK['source_date_epoch'],'container_epoch':epoch,
|
|
'all_base_layers_and_rootless_runtime_config_preserved':True,'all_oci_blob_digests_verified':True,
|
|
'final_layer_binary_exact':True,'registry_publication':False,'deployment_tag':None,
|
|
'host_or_secret_mounts':False,'privileged_containers':False,'local_runtime_image_loaded':False})
|
|
print((work/'container-result.json').read_text())
|
|
|
|
def main():
|
|
parser=argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('phase',choices=['prepare','acquire-npm','build','verify','container'])
|
|
parser.add_argument('--workspace',required=True)
|
|
for n in ['source-archive','go-archive','node-archive','module-cache','go-cache-seed','npm-cache-seed']:parser.add_argument('--'+n)
|
|
a=parser.parse_args();work=Path(a.workspace).resolve()
|
|
if not str(work).startswith('/tmp/'):raise ValueError('isolated build workspace must be under /tmp')
|
|
if a.phase=='prepare':return prepare(a,work)
|
|
assert json.loads((work/'prepared.json').read_text())['lock_sha256']==digest(INPUTS/'inputs.lock.json')
|
|
if a.phase=='acquire-npm':
|
|
checked_inputs(work);env=base_env(work);env['NPM_CONFIG_OFFLINE']='false'
|
|
return run(work,'npm-public-acquire',['npm','ci','--ignore-scripts','--no-audit','--no-fund'],env,cwd=work/'source')
|
|
{'build':build,'verify':verify,'container':container}[a.phase](work)
|
|
|
|
if __name__=='__main__':main()
|