railiance-apps/tools/forgejo-release-build.py
tegwick 3fb031a47f
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Compose Forgejo private-input and authenticated decision build candidate
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a14bfc-9643-7872-aa12-98326dcf25e7
2026-10-18 02:21:33 +02:00

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()