From 379c31a88aba0059804a145ddcda47656c8c984e Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 29 Oct 2026 19:46:29 -0700 Subject: [PATCH] website: feature the 0.3.4 English preview and GitHub mirror --- website/.gitignore | 8 ++ website/README.md | 59 ++++++++++++++ website/assets/favicon.svg | 1 + website/assets/logo.svg | 1 + website/assets/video-poster.svg | 1 + website/build_site.py | 122 +++++++++++++++++++++++++++++ website/qa_site.py | 125 ++++++++++++++++++++++++++++++ website/release.example.json | 74 ++++++++++++++++++ website/release_catalog.py | 132 ++++++++++++++++++++++++++++++++ website/src/site.css | 11 +++ website/src/site.js | 12 +++ website/test_release_catalog.py | 73 ++++++++++++++++++ 12 files changed, 619 insertions(+) create mode 100644 website/.gitignore create mode 100644 website/README.md create mode 100644 website/assets/favicon.svg create mode 100644 website/assets/logo.svg create mode 100644 website/assets/video-poster.svg create mode 100644 website/build_site.py create mode 100644 website/qa_site.py create mode 100644 website/release.example.json create mode 100644 website/release_catalog.py create mode 100644 website/src/site.css create mode 100644 website/src/site.js create mode 100644 website/test_release_catalog.py diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000..cf702ef --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,8 @@ +dist/ +qa/ +release.json +__pycache__/ +assets/framebridge-preview-0.3.1-original-r4.mp4 +assets/framebridge-preview-0.3.1-original-r4.jpg +assets/framebridge-preview-0.3.4-en.mp4 +assets/framebridge-preview-0.3.4-en.jpg diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000..7d68ef3 --- /dev/null +++ b/website/README.md @@ -0,0 +1,59 @@ +# FrameBridge website source + +The original visual design and renderer are retained. The main workflow is +administration of a home Linux network by one human administrator. Compatible +desktop access is an auxiliary tool. + +The canonical source repository is the existing GERANAS Gitea project +https://framebridge.betancourt.one/git/gerardo/framebridge. CLOUD provides the +existing HTTPS reverse proxy; GERANAS serves the website, downloads and APT +repository. Do not substitute a static Git mirror or another hosting backend. + +Copy release.example.json to release.json, then fill it from the exact verified +release inventory. Its three profiles are Debian 13 amd64/Python 3.13 (trixie), +Ubuntu 24.04 amd64/Python 3.12 (noble), and Linux Mint 22.x amd64/Python 3.12 +(mint22). A preparing catalog offers no new download or installation commands. +Use ready only after verifying all DEBs, corresponding source, dependency +source archives, signatures, checksums and the official public fingerprint. +Metadata describes release artifacts; it is not itself evidence that a public +hostname is operational. + +Project media is delivered separately. Copy the approved original R4 MP4 and +JPEG into assets under their original filenames. The renderer verifies these +identities before a ready build: + +- framebridge-preview-0.3.1-original-r4.mp4: + b2a8b0b302063edbe05d84fb6dadb7f303a6f0f1b6d79dc151287df4671adc5b +- framebridge-preview-0.3.1-original-r4.jpg: + 2e867d4b6a22f81ecbefa293a88799b9e3c93f02aa7d1090c7f0a0dbfe44dc9f + +The homepage features the approved 80-second English 0.3.4 conceptual animation +with the original FrameBridge music. It is not a live screen recording. Copy its +assets into the same directory before a ready build: + +- framebridge-preview-0.3.4-en.mp4: + 61fd8eaa38f928ffd2cc9d60d8fa051821fb569fd58583a0d42ad201d2e04563 +- framebridge-preview-0.3.4-en.jpg: + 0497c3977bcdc47d0db99bc37a71235671cf3f1b95a31f5b6b020584a5d09974 + +The approved original 0.3.1 MP4 and JPEG remain unchanged and publicly accessible. +The homepage links to the original clip. The GitHub repository is an additional +public mirror: https://github.com/gerabh1128/framebridge. The canonical Gitea +repository and release catalog remain unchanged. + +Run: + + python3 -I -S build_site.py + python3 -I -S qa_site.py + python3 -I -S test_release_catalog.py + +The generator reads ../source/docs/COMMAND-GUIDE.json and requires its version +to match the release. QA checks local links/assets, loopback HTTP, the guarded +ready renderer with a disposable synthetic inventory, and syntax of all three +APT setup blocks. The test fixtures never sign, publish or install anything. +Perform final HTTPS, Gitea clone and isolated APT verification through the +actual public domain before reporting publication complete. + +The website follows the project's GPL-3.0-only license. Third-party materials +retain their own licenses and notices. Support is voluntary; the contact and +PayPal account supplied by the project owner is gerardobh1128@gmail.com. diff --git a/website/assets/favicon.svg b/website/assets/favicon.svg new file mode 100644 index 0000000..aa24a01 --- /dev/null +++ b/website/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/assets/logo.svg b/website/assets/logo.svg new file mode 100644 index 0000000..7707388 --- /dev/null +++ b/website/assets/logo.svg @@ -0,0 +1 @@ +FrameBridge terminal prompt>framebridge \ No newline at end of file diff --git a/website/assets/video-poster.svg b/website/assets/video-poster.svg new file mode 100644 index 0000000..e61672e --- /dev/null +++ b/website/assets/video-poster.svg @@ -0,0 +1 @@ +FRAMEBRIDGE / CONCEPTUAL PREVIEW>framebridgeA conversation becomes a workflow.Conceptual animation · Not a screen recording \ No newline at end of file diff --git a/website/build_site.py b/website/build_site.py new file mode 100644 index 0000000..b00d9d0 --- /dev/null +++ b/website/build_site.py @@ -0,0 +1,122 @@ +#!/usr/bin/python3 +"""Build the self-hosted FrameBridge website with the Python standard library.""" +from pathlib import Path +import html,json,re,shutil,sys,runpy,hashlib + +BASE=Path(__file__).resolve().parent +ROOT=BASE.parent +DIST=BASE/'dist' +META=json.loads((BASE/'release.json').read_text()) +META.setdefault('git_url','/git/gerardo/framebridge') +META.setdefault('git_clone_url','https://framebridge.betancourt.one/git/gerardo/framebridge.git') +E=lambda value:html.escape(str(value),quote=True) +READY=META.get('state')=='ready' +CAT=runpy.run_path(str(BASE/'release_catalog.py')) +CAT['validate'](META,READY) +VIDEO=CAT['VIDEO'];POSTER=CAT['POSTER'] +PREVIEW='framebridge-preview-0.3.4-en.mp4' +PREVIEW_POSTER='framebridge-preview-0.3.4-en.jpg' +PREVIEW_SHA256='61fd8eaa38f928ffd2cc9d60d8fa051821fb569fd58583a0d42ad201d2e04563' +PREVIEW_POSTER_SHA256='0497c3977bcdc47d0db99bc37a71235671cf3f1b95a31f5b6b020584a5d09974' +GITHUB='https://github.com/gerabh1128/framebridge' +for name,expected in [(VIDEO,CAT['VIDEO_SHA256']),(POSTER,CAT['POSTER_SHA256']), + (PREVIEW,PREVIEW_SHA256),(PREVIEW_POSTER,PREVIEW_POSTER_SHA256)]: + asset=BASE/'assets'/name + if asset.exists(): + assert hashlib.sha256(asset.read_bytes()).hexdigest()==expected,'Approved project media changed.' + elif READY: + raise ValueError('Approved project media is required before publication.') +for path in ['assets','docs','download','blog/2026-10-26-opening-framebridge','community','support']: + (DIST/path).mkdir(parents=True,exist_ok=True) +for p in (BASE/'src').iterdir(): + if p.is_file():shutil.copyfile(p,DIST/'assets'/p.name) +for p in (BASE/'assets').iterdir(): + if p.is_file():shutil.copyfile(p,DIST/'assets'/p.name) +(DIST/'assets/release.json').write_text(json.dumps(META,indent=2)+'\n') + +def brand(): + return 'framebridge' + +def header(current): + items=[('Docs','/docs/','docs'),('Development','/blog/','blog'),('Source',META['git_url'],'source'),('GitHub',GITHUB,'github'),('Support','/support/','support'),('Download','/download/','download')] + links=[] + for title,url,key in items: + links.append(''+E(title)+'') + return '' + +def footer(): + return '''''' + +def page(title,body,current='',light=False,description='FrameBridge connects your own ChatGPT account to Linux systems you control.'): + return ''+E(title)+' · FrameBridge'+header(current)+body+footer()+'' + +def code(text): + return '
'+E(text)+'
' + +release_label='Official release' if READY else 'Release preparation' +home='''
Home Linux networks · One administrator

Your Linux network.
One place to administer it.

Administer your own home Linux network through ChatGPT: inspect equipment, diagnose connections, manage services and run commands across enrolled computers. One administrator, one bridge.

GPL-3.0-only · Debian 13 · Ubuntu 24.04 · Linux Mint 22.x · amd64

FRAMEBRIDGE / EXAMPLE WORKFLOW

$fb connect

Connect your own account through the guided terminal flow.

$fb status

See tools, connection and startup state in one place.

$fb console

Return to your work view: Bash and FBTERM activity.

Illustrative commands. This is not a screen capture.

Local executionPersistent terminalsNetwork administrationCentral + nodes
+

From a blinking cursor
to a connected workflow.

A conceptual look at independent tasks, consoles and connected computers. The software runs your tools on your systems; the ChatGPT model remains hosted.

Conceptual animation — not a screen recording.FrameBridge 0.3.4 · English · 80 seconds · Original music. Watch the original 0.3.1 concept preview.

+

Start with one machine.
Extend when you need to.

A standalone computer can provide local tools. A central installation can also connect approved nodes.

Where work happens
Your ChatGPT accountFrameBridgeYour Linux systems

No phone pairing is required. Initial account consent and credentials stay in the normal browser or hidden terminal prompts.

  1. 01

    Install and connect

    Install the package for your distribution, then use the guided text interface to connect your own account. Advanced role setup is optional for ordinary standalone use.

  2. 02

    Choose the right tool

    Use FBEXEC for commands, persistent terminals for ongoing shell work, and graphical CUA when a compatible desktop is available.

  3. 03

    Keep the context

    Return to a live terminal session without rebuilding its shell state between calls. Running processes do not survive an operating-system reboot.

  4. 04

    Add approved nodes

    Use the central instance for ChatGPT transport and select enrolled nodes explicitly. Nodes do not need a second ChatGPT tunnel.

+

Execution

Run authorized shell tasks on the selected machine and inspect their recorded results.

FBEXEC

Terminals

Keep a real Bash session across calls. Inspect text, send input and return to ongoing work.

FBTERM / fb tty

Desktop and text

Use compatible graphical and text access as auxiliary tools for your own equipment. Commands, diagnostics and network administration remain the main workflow.

CUA / CUA-TTY

People and agents

The same command-line interface serves human operators and authorized ChatGPT agents, with discoverable usage guidance.

fb / framebridge

Independent FBEXEC tasks have been used together in the laboratory when their objectives and changes did not overlap. This is observed practice, not an isolation guarantee. Use one controller at a time for each shared graphical desktop.

+

Free software.
Verifiable releases.

Read the code, adapt it and contribute. Official downloads are identified by their published checksums and release signatures.

  • Version'''+E(META['version'])+'''
  • Platform'''+E(META['distribution'])+'''
  • LicenseGNU GPLv3 only
  • Channel'''+E(release_label)+'''
+

A small project.
An open invitation.

Good tools improve through use, careful fixes and clear documentation. Report a reproducible issue, improve the guide or propose a change in Git.

How to contribute
''' +(DIST/'index.html').write_text(page('Administer your home Linux network',home)) + +download=CAT['download'](META) +(DIST/'download/index.html').write_text(page('Download and verify',download,'download')) + +community='''

Start with the existing behavior

Read the versioned guide, check the installed version and look for an existing issue. A reproducible report should name the command, expected result, actual result and relevant environment.

Remove credentials and personal data from logs before sharing them. Never include passwords, access keys, private keys, tokens or cookies.

Propose a focused change

Use an issue to explain the problem or a pull request to propose a patch. Include the reason for the change and proportionate validation. Keep unrelated edits separate so they can be reviewed.

'''+('

Clone the project

'+code('git clone '+META['git_clone_url']) if META.get('git_clone_url') else '')+'''

Open the project in Git

The canonical repository is hosted on GERANAS. You can also browse the GitHub mirror and contribute there.

Official releases and free forks

Maintainers review which changes become official FrameBridge releases. Published release checksums and signatures identify those artifacts.

GNU GPLv3 also allows private modifications and independently distributed forks, subject to its terms. Contributing changes upstream is welcome; it is not a condition for exercising those rights. Modified builds should be clearly marked and must not be presented as official project releases.

Read the GNU GPLv3 license. Third-party components retain their existing notices and licenses.

Using an agent to contribute

The command interface and MCP guide are designed for both people and authorized agents. Normal operation uses installed commands and tools. Editing or rebuilding the application is a development task that should follow the owner’s instructions.

Project credits

Programming: ChatGPT
Original concept and project coordination: Gerardo Betancourt

FrameBridge is an independent project. The credit describes how the project was developed and does not imply endorsement by OpenAI.

''' +(DIST/'community/index.html').write_text(page('Contribute',community,'source')) + +post='''

FrameBridge began with a practical goal: use a conversation with ChatGPT to do useful work on Linux computers we control. Commands, persistent terminals and a compatible desktop should fit into the same working context.

Make the interface easier to understand

The current development work brings everyday commands into a shorter vocabulary, keeps status observation separate from diagnosis, and explains the effects of connection and startup controls. The long command framebridge and its short alias fb use the same interface.

The agent also needs a reliable starting point. A versioned guide and a brief MCP invitation are intended to help it discover how the installed software works, which computer receives an operation and what a command will change.

Keep the evidence honest

The preview video is a conceptual animation, not a recording of a live session. Real demonstrations and captures must remain distinguishable from illustrations. Independent tasks have been used together in the laboratory, but that experience does not establish automatic isolation or exclusive desktop control.

Open source, with identifiable releases

The project is being prepared for GNU GPLv3 distribution. Sources and package artifacts should be published together, with signatures and checksums identifying official releases. Community members remain free to adapt and redistribute the software under the license.

The first contribution can be small: a clearer explanation, a reproducible issue or a focused fix. Review gives those changes a path into a later official version.

Read the contribution guide →

''' +(DIST/'blog/2026-10-26-opening-framebridge/index.html').write_text(page('Opening FrameBridge',post,'blog')) +support='''

What support can help with

  • Hosting and distribution: keeping the source code, documentation and release files available.
  • Maintenance: investigating reported problems and keeping the software usable.
  • Documentation: improving clear instructions for people and agents.
  • Development: building and reviewing focused improvements.

Voluntary support and contact

Support through PayPal

Project contact and PayPal account: gerardobh1128@gmail.com.

Contributions are voluntary. FrameBridge remains free software under GNU GPLv3 only, whether or not you provide financial support.

Support without a payment

A useful bug report, a clearer explanation or a carefully scoped patch can be valuable contributions. You can also help by sharing honest demonstrations that distinguish real recordings from conceptual illustrations.

Read the contribution guide →

Review stays independent

Financial support does not grant control over official releases, bypass code review or security checks, or imply a project endorsement. Official changes follow the same review and release process.

Explore the source code →

''' +(DIST/'support/index.html').write_text(page('Support FrameBridge',support,'support')) + +blog='''
''' +(DIST/'blog/index.html').write_text(page('Development journal',blog,'blog')) + +guide_path=ROOT/'repository/source/docs/COMMAND-GUIDE.json' +if not guide_path.exists(): + guide_path=ROOT/'source/docs/COMMAND-GUIDE.json' +guides={} +guide_data={} +if guide_path.exists(): + guide_data=json.loads(guide_path.read_text()) + guides=guide_data.get('topics',{}) + assert isinstance(guides,dict) and all(isinstance(v,dict) for v in guides.values()) + assert guide_data.get('version')==META['version'],'Guide and release versions must match.' + (DIST/'assets/command-guide.json').write_text(json.dumps(guide_data,indent=2)+'\n') +order=[x for x in ['start','commands','connection','lifecycle','nodes','terminals','screens','diagnostics','security'] if x in guides] +order += [x for x in guides if x not in order] + +def docs_nav(current): + entries='
  • Guide overview
  • ' + for topic in order: + entries+='
  • '+E(guides[topic].get('title',topic.title()))+'
  • ' + return '' + +def paragraphs(body): + if isinstance(body,list):body='\n\n'.join(str(x) for x in body) + return ''.join('

    '+E(x).replace('\n','
    ')+'

    ' for x in str(body).split('\n\n') if x.strip()) + +for topic in order: + guide=guides[topic] + article='

    '+E(guide.get('title',topic))+'

    ' + for section in guide.get('sections',[]): + article+='

    '+E(section.get('heading',''))+'

    '+paragraphs(section.get('body','')) + for command in section.get('commands',[]): + article+=code(command if isinstance(command,str) else json.dumps(command,indent=2)) + article+='

    This page is generated from the same versioned guide used by the CLI and MCP help tool. Use fb help '+E(topic)+' for the local reference.

    ' + dest=DIST/'docs'/topic;dest.mkdir(parents=True,exist_ok=True) + (dest/'index.html').write_text(page(guide.get('title',topic),'
    '+docs_nav(topic)+article+'
    ','docs',True)) +hub='

    The command guide.

    One reference for human operators and authorized ChatGPT agents. The commands fb and framebridge use the same interface.

    Start with the normal connection flow. Use advanced setup only when you need to change roles or profile settings.

    '+code('fb connect\nfb status\nfb help')+'' +if not guides:hub+='
    The shared command-guide export is being finalized. This staging page is not the complete published reference yet.
    ' +else:hub+='

    Read the versioned guide data

    ' +hub+='
    ' +(DIST/'docs/index.html').write_text(page('Command guide','
    '+docs_nav('index')+hub+'
    ','docs',True)) +(DIST/'404.html').write_text(page('Page not found','

    This path ends here.

    Return to FrameBridge or open the command guide.

    ')) +(DIST/'robots.txt').write_text('User-agent: *\n'+('Allow: /\n' if READY else 'Disallow: /\n')) +print(json.dumps({'web_root':str(DIST),'release_state':META['state'],'pages':len(list(DIST.rglob('*.html'))),'guide_topics':len(guides),'video_present':(DIST/'assets'/VIDEO).is_file(),'ready_metadata_validated':READY},indent=2)) diff --git a/website/qa_site.py b/website/qa_site.py new file mode 100644 index 0000000..4c17ac6 --- /dev/null +++ b/website/qa_site.py @@ -0,0 +1,125 @@ +#!/usr/bin/python3 +"""Bounded static and loopback HTTP checks for the FrameBridge website.""" +from pathlib import Path +from html.parser import HTMLParser +from urllib.parse import urlsplit,unquote +from urllib.request import urlopen +from http.server import ThreadingHTTPServer,SimpleHTTPRequestHandler +import json,hashlib,threading,tempfile,shutil,subprocess,html,os +BASE=Path(__file__).resolve().parent +DIST=BASE/'dist' +REPORT=BASE/'qa/WEBSITE-STATUS.json' +REPORT.parent.mkdir(mode=0o700,exist_ok=True) +class Inspect(HTMLParser): + def __init__(self): + super().__init__();self.links=[];self.assets=[];self.ids=set();self.lang=None;self.main=False;self.scripts=[];self.code=[];self.in_code=False;self.current='' + def handle_starttag(self,tag,attrs): + a=dict(attrs) + if tag=='html':self.lang=a.get('lang') + if 'id' in a:self.ids.add(a['id']) + if tag=='main' and a.get('id')=='content':self.main=True + if tag=='a' and 'href'in a:self.links.append(a['href']) + if tag in ('script','source','img') and 'src'in a:self.assets.append(a['src']) + if tag=='video' and 'poster'in a:self.assets.append(a['poster']) + if tag=='link' and a.get('rel') in ('stylesheet','icon'):self.assets.append(a['href']) + if tag=='script':self.scripts.append(a) + if tag=='code':self.in_code=True;self.current='' + def handle_data(self,data): + if self.in_code:self.current+=data + def handle_endtag(self,tag): + if tag=='code':self.code.append(self.current);self.in_code=False +def resolve(path): + p=DIST/unquote(path.lstrip('/')) + return p/'index.html' if path.endswith('/') or p.is_dir() else p +def main(): + os.umask(0o077);pages=sorted(DIST.rglob('*.html'));assert pages + failures=[];pending=[];infra=set();http_paths=set();external=set() + for p in pages: + raw=p.read_text();q=Inspect();q.feed(raw);rel='/'+str(p.relative_to(DIST));http_paths.add(rel) + if q.lang!='en' or not q.main:failures.append(str(p.relative_to(DIST))+': missing English or main landmark') + if '/home/' in raw or '/srv/geranas-services/' in raw or '/workspace/' in raw:failures.append(str(p.relative_to(DIST))+': personal host path') + if "'unsafe-inline'" in raw:failures.append(str(p.relative_to(DIST))+': unsafe inline CSP') + for value in q.links+q.assets: + u=urlsplit(value) + if u.scheme or u.netloc: + external.add(value);continue + if not u.path: + if u.fragment and u.fragment not in q.ids:failures.append(str(p.relative_to(DIST))+': missing anchor '+u.fragment) + continue + if any(u.path.startswith(prefix) for prefix in ['/git/','/debian/','/downloads/']): + infra.add(u.path);continue + if not u.path.startswith('/'):failures.append('Non-root relative URL '+value);continue + target=resolve(u.path) + if not target.exists(): + if u.path=='/assets/framebridge-preview-0.3.1-original-r4.mp4':pending.append('Preview video asset pending') + else:failures.append('Missing local target '+value+' referenced by '+str(p.relative_to(DIST))) + elif u.path in q.assets:http_paths.add(u.path) + if u.fragment and target.is_file() and target.suffix=='.html': + other=Inspect();other.feed(target.read_text()) + if u.fragment not in other.ids:failures.append('Missing target fragment '+value) + for item in q.scripts: + if item.get('src')!='/assets/site.js':failures.append('Unexpected script source') + css=(DIST/'assets/site.css').read_text() + assert css.count('{')==css.count('}'),'CSS brace mismatch' + class Quiet(SimpleHTTPRequestHandler): + def __init__(self,*a,**k):super().__init__(*a,directory=str(DIST),**k) + def log_message(self,*a):pass + server=ThreadingHTTPServer(('127.0.0.1',0),Quiet);thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() + http_count=0 + try: + for path in sorted(http_paths): + with urlopen('http://127.0.0.1:'+str(server.server_port)+path,timeout=3) as response: + if response.status!=200:failures.append('HTTP status '+str(response.status)+' for '+path) + response.read();http_count+=1 + finally:server.shutdown();server.server_close();thread.join(timeout=2) + # Exercise the guarded final-metadata branch using clearly synthetic values + # in a disposable directory. No signature or installation command executes. + meta=json.loads((BASE/'release.json').read_text()) + with tempfile.TemporaryDirectory(prefix='framebridge-web-qa-') as td: + temp=Path(td);web=temp/'web';web.mkdir() + shutil.copyfile(BASE/'build_site.py',web/'build_site.py') + shutil.copyfile(BASE/'release_catalog.py',web/'release_catalog.py') + shutil.copytree(BASE/'src',web/'src') + (web/'assets').mkdir() + for p in (BASE/'assets').iterdir(): + if p.is_file():shutil.copyfile(p,web/'assets'/p.name) + fixture=json.loads(json.dumps(meta));fixture.update(state='ready',signing_fingerprint='2B1F01BC806C0B33CB6CEDDE52859CEBC91B8808',checksums_url='/downloads/TEST-SHA256SUMS',checksums_signature_url='/downloads/TEST-SHA256SUMS.asc') + fixture['source']=dict(filename='TEST-SOURCE.tar.gz',url='/downloads/TEST-SOURCE.tar.gz',signature_url='/downloads/TEST-SOURCE.tar.gz.asc',sha256='a'*64,size_bytes=1) + for i,profile in enumerate(fixture['profiles']): + filename='TEST-profile-'+str(i)+'.deb' + profile.update(filename=filename,url='/downloads/'+filename,signature_url='/downloads/'+filename+'.asc',sha256='a'*64,size_bytes=1) + for dependency in fixture['dependency_sources']: + dependency.setdefault('signature_url',dependency['url']+'.asc') + (web/'release.json').write_text(json.dumps(fixture)) + result=subprocess.run(['/usr/bin/python3','-I','-S',str(web/'build_site.py')],capture_output=True,text=True,timeout=10) + if result.returncode:failures.append('Synthetic-ready rendering failed: '+result.stderr[-600:]) + else: + q=Inspect();q.feed((web/'dist/download/index.html').read_text()) + blocks=[x for x in q.code if '\n' in x] + for text in blocks: + result=subprocess.run(['/bin/bash','-n'],input=text,text=True,capture_output=True,timeout=3) + if result.returncode:failures.append('Rendered install block shell syntax: '+result.stderr.strip()) + apt_blocks=[x for x in blocks if 'sources.list.d/framebridge.sources' in x] + if len(apt_blocks)!=3:failures.append('Expected three independent APT profile setup blocks') + for apt in apt_blocks: + if 'Signed-By:' not in apt or 'primary_count' not in apt or 'apt-key' in apt: + failures.append('APT key trust must be pinned and scoped with Signed-By') + for suite in ['trixie','noble','mint22']: + if not any('Suites: '+suite in block for block in apt_blocks): + failures.append('Missing APT suite '+suite) + status={'host':'GERAMEG','website_source':str(BASE),'web_root':str(DIST),'language':'English','buildless':True,'html_pages_checked':len(pages),'loopback_http_paths_checked':http_count,'static_failures':failures,'pending_assets':sorted(set(pending)),'infra_routes_expected':sorted(infra),'external_links':sorted(external),'no_external_runtime_dependencies':True,'copy_controls_progressive':True,'ready_branch_rendered_with_synthetic_fixture':True,'install_blocks_syntax_checked_not_executed':True,'release_state':meta.get('state'),'package_sha256_present':all(p.get('sha256') for p in meta.get('profiles',[])),'signing_fingerprint_present':bool(meta.get('signing_fingerprint')),'video_present':(DIST/'assets/framebridge-preview-0.3.1-original-r4.mp4').is_file(),'visual_browser_QA_performed':False,'visual_browser_QA_limit':'No browser or user desktop was operated by this subtask. Root may inspect the staged site via the authorized GERAMEG surface.','production_network_or_services_changed':False,'site_ready_for_private_staging':not failures,'public_release_ready':not failures and not pending and meta.get('state')=='ready','homepage_sha256':hashlib.sha256((DIST/'index.html').read_bytes()).hexdigest()} + visual=BASE/'qa/VISUAL-REVIEW.json' + if visual.exists(): + status['visual_browser_QA_performed']=True + status['visual_browser_QA_limit']=json.loads(visual.read_text())['limitations'] + status['visual_browser_QA_report']=str(visual) + status.update(support_page='/support/',support_funding_channel_configured='https://www.paypal.com/donate/' in (DIST/'support/index.html').read_text(),support_payment_form_present=' 0, "Exact artifact size required") + require(re.fullmatch("[A-Za-z0-9][A-Za-z0-9._+~-]*", spec.get("filename", "")), "Safe artifact filename required") + project_url(spec.get("url", ""), "/downloads/") + project_url(spec.get("signature_url", ""), "/downloads/") + +def validate(meta, ready): + require(re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", meta.get("version", "")), "Release version must be numeric") + require(meta.get("state") in ("preparing", "ready"), "Unknown release state") + require(meta.get("site_url") == "https://framebridge.betancourt.one", "Canonical site origin required") + require(meta.get("package_name") == "framebridge-original-expanded", "Unexpected binary package") + require(meta.get("git_url") == "/git/gerardo/framebridge", "Use existing canonical Gitea repository") + require(meta.get("git_clone_url") == "https://framebridge.betancourt.one/git/gerardo/framebridge.git", + "Use existing canonical Gitea clone URL") + expected = {x[0]: x[1:] for x in PROFILES} + profiles = meta.get("profiles", []) + require(isinstance(profiles, list) and len(profiles) == 3, "Exactly three profiles required") + require(len({p.get("profile_id") for p in profiles}) == 3, "Duplicate profile") + for profile in profiles: + identity = profile.get("profile_id") + require(identity in expected, "Unexpected profile") + suite, label, python = expected[identity] + require((profile.get("suite"), profile.get("label"), profile.get("python"), + profile.get("architecture")) == (suite, label, python, "amd64"), + "Profile identity does not match its tested distribution") + if ready: + asset(profile) + if ready: + require(meta.get("signing_fingerprint") == FINGERPRINT, "Unexpected official signing fingerprint") + asset(meta.get("source")) + deps = meta.get("dependency_sources", []) + require(isinstance(deps, list) and len(deps) >= 2, "Dependency corresponding-source metadata required") + for dependency in deps: + asset(dependency) + for key, prefix in (("signing_key_url", "/debian/"), ("checksums_url", "/downloads/"), + ("checksums_signature_url", "/downloads/")): + project_url(meta.get(key, ""), prefix) + +def code(text): + return '
    ' + E(text) + '
    ' + +def apt_commands(meta, profile): + require(meta.get("state") == "ready", "Do not offer installation commands for an unsigned preparation") + validate(meta, True) + key_url = meta["site_url"] + meta["signing_key_url"] if meta["signing_key_url"].startswith("/") else meta["signing_key_url"] + return """sudo apt install ca-certificates curl gnupg +( + set -eu + key_file="$(mktemp)" + trap 'rm -f "$key_file"' EXIT + curl -fsSL '""" + key_url + """' -o "$key_file" + key_info="$(gpg --batch --show-keys --with-colons "$key_file" 2>/dev/null)" + actual="$(printf '%s\\n' "$key_info" | awk -F: '$1=="fpr" {print $10; exit}')" + primary_count="$(printf '%s\\n' "$key_info" | awk -F: '$1=="pub" {n++} END {print n+0}')" + test "$primary_count" -eq 1 + test "$actual" = '""" + FINGERPRINT + """' + sudo install -d -m 0755 /etc/apt/keyrings + sudo install -m 0644 "$key_file" /etc/apt/keyrings/framebridge-archive-keyring.asc + printf '%s\\n' \\ + 'Types: deb' \\ + 'URIs: https://framebridge.betancourt.one/debian/' \\ + 'Suites: """ + profile["suite"] + """' \\ + 'Components: main' \\ + 'Architectures: amd64' \\ + 'Signed-By: /etc/apt/keyrings/framebridge-archive-keyring.asc' \\ + | sudo tee /etc/apt/sources.list.d/framebridge.sources >/dev/null + sudo apt update + sudo apt install framebridge-original-expanded +)""" + +def download(meta): + ready = meta["state"] == "ready" + body = '
    ' + body += '

    FrameBridge helps one administrator manage their own home Linux network: inventory, services, storage, diagnostics, commands and persistent terminals. Desktop access is an auxiliary tool.

    ' + body += '

    Match the target computer

    ' + code("cat /etc/os-release\ndpkg --print-architecture\npython3 --version") + body += '

    The supported profiles below use amd64 and the distribution system Python. Select one matching suite for each computer.

    ' + if not ready: + body += '
    Release preparation. Version ' + E(meta["version"]) + ' is being prepared. Download links and installation commands appear after the exact artifacts and signatures are verified.
    ' + body += '
    ' + for profile in meta["profiles"]: + body += '

    ' + E(profile["label"]) + '

    APT suite: ' + E(profile["suite"]) + '

    ' + if ready: + body += '

    SHA-256: ' + E(profile["sha256"]) + '

    ' + E(profile["filename"]) + ' · ' + format(profile["size_bytes"], ",") + ' bytes

    Install the signed APT channel' + code(apt_commands(meta, profile)) + '
    ' + else: + body += '

    Version ' + E(meta["version"]) + ' · package and signature pending.

    ' + body += '
    ' + body += '
    ' + if ready: + body += '

    Release verification and source

    Expected signing-key fingerprint:

    ' + FINGERPRINT + '

    SHA256SUMS · Checksum signature · Public key

    Corresponding FrameBridge source · Source signature

    SHA-256: ' + E(meta["source"]["sha256"]) + '

    Third-party components retain their original licenses and notices. Unchanged dependency archives retain their historical filenames.

    Normal updates

    ' + code("sudo apt update\napt-cache policy framebridge-original-expanded\nsudo apt install --only-upgrade framebridge-original-expanded\nfb status") + body += '

    APT verifies the repository signature and package hashes. Keep the selected distribution suite and its scoped Signed-By key.

    ' + body += '

    Connect and work

    After installation, connect from the normal terminal on that computer:

    ' + code("fb connect\nfb status\nfb console") + '

    One central installation can connect enrolled Linux nodes. Nodes use the central connection. Enter passwords and connection keys only in the normal local prompts.

    Read the shared command guide · Browse the canonical source repository

    ' + return body diff --git a/website/src/site.css b/website/src/site.css new file mode 100644 index 0000000..705bbd6 --- /dev/null +++ b/website/src/site.css @@ -0,0 +1,11 @@ +@charset "UTF-8"; +:root{color-scheme:dark;--bg:#111715;--panel:#19211e;--ink:#f1f1e8;--muted:#aeb9b1;--line:#33443b;--accent:#eac27b;--mint:#9dd5b7;--mono:ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",monospace;--sans:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--ink);font:17px/1.65 var(--sans);-webkit-font-smoothing:antialiased}a{color:inherit;text-decoration-thickness:1px;text-underline-offset:.22em}button,a{-webkit-tap-highlight-color:transparent}a:hover{color:var(--accent)}a:focus-visible,button:focus-visible,summary:focus-visible{outline:3px solid var(--accent);outline-offset:5px}button{font:inherit}img,video{max-width:100%;display:block}code,pre,.mono{font-family:var(--mono)}code{font-size:.9em}pre{white-space:pre-wrap;overflow-wrap:anywhere;margin:0;font-size:14px;line-height:1.7}h1,h2,h3,p{margin-top:0}h1,h2,h3{line-height:1.13;letter-spacing:-.045em;font-weight:550}h1{font-size:clamp(48px,6.1vw,88px);max-width:12ch;margin-bottom:28px}h2{font-size:clamp(32px,4vw,52px);max-width:21ch;margin-bottom:25px}h3{font-size:23px;letter-spacing:-.025em}p{margin-bottom:20px}::selection{background:var(--accent);color:#111715}.wrap{width:min(1200px,calc(100% - 64px));margin:auto}.narrow{max-width:820px}.skip{position:fixed;z-index:99;top:-80px;left:20px;padding:12px 18px;background:var(--accent);color:#111}.skip:focus{top:12px}.site-header{border-bottom:1px solid var(--line)}.header-inner{min-height:100px;display:flex;align-items:center;justify-content:space-between;gap:28px}.brand{font-family:var(--mono);font-size:24px;letter-spacing:-.06em;text-decoration:none;white-space:nowrap}.brand .prompt{color:var(--accent);margin-right:9px}.cursor{color:var(--accent);animation:blink 1.5s steps(1) infinite}nav{display:flex;gap:28px;align-items:center;font-size:14px}nav a{text-decoration:none;color:var(--muted)}nav a[aria-current=page]{color:var(--ink)}.nav-download{padding:9px 16px;border:1px solid var(--line);border-radius:2px;color:var(--ink)} +.hero{padding:88px 0 84px;display:grid;grid-template-columns:1.15fr .85fr;gap:80px;align-items:center}.eyebrow{text-transform:uppercase;letter-spacing:.14em;font-size:11px;font-weight:650;color:var(--mint);margin-bottom:28px;display:flex;gap:10px;align-items:center}.eyebrow:before{content:"";display:block;width:7px;height:7px;border-radius:50%;background:var(--mint)}.hero .lead{max-width:42ch;font-size:20px;line-height:1.6;color:var(--muted);margin-bottom:32px}.actions{display:flex;gap:15px;align-items:center;flex-wrap:wrap}.button{display:inline-flex;align-items:center;justify-content:center;padding:13px 21px;border:1px solid var(--line);text-decoration:none;font-size:14px;font-weight:600;border-radius:2px;min-height:48px}.button.primary{background:var(--accent);border-color:var(--accent);color:#171b16}.button:hover{border-color:var(--accent)}.button.primary:hover{background:#f5d8a8;color:#171b16}.button.disabled{color:var(--muted);cursor:default}.micro{font-size:12px;color:var(--muted);margin-top:20px}.terminal{border:1px solid #42564a;background:#0b110e;box-shadow:12px 15px 0 #1b2821}.terminal-top{display:flex;justify-content:space-between;gap:12px;padding:14px 20px;border-bottom:1px solid #33443b;color:#95aa9b;font:11px var(--mono)}.terminal-body{padding:28px 26px}.term-row{border-left:1px solid #2d4134;padding:0 0 24px 20px;font-family:var(--mono)}.term-row:last-child{padding-bottom:0}.term-command{font-size:15px;color:#f3d79f;margin:0 0 8px}.term-command span{color:#668775;margin-right:10px}.term-note{color:#8fa796;font-size:11px;line-height:1.7;max-width:27ch}.terminal-caption{font-size:11px;letter-spacing:.04em;color:var(--muted);margin-top:26px;text-align:right} +.rule{border-top:1px solid var(--line)}.strip{padding:24px 0;display:flex;gap:35px;justify-content:space-between;font:12px var(--mono);color:var(--muted)}.strip span:before{content:"+";margin-right:10px;color:var(--accent)}.section{padding:85px 0}.section-head{display:grid;grid-template-columns:1fr 1fr;gap:60px;align-items:start;margin-bottom:40px}.section-head p{color:var(--muted);max-width:46ch;padding-top:6px}.section-label{font:11px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--mint);margin-bottom:24px}.video-shell{background:#080c0a;border:1px solid var(--line)}.video-shell video{width:100%;aspect-ratio:16/9;background:#080c0a;object-fit:contain}.video-shell.portrait{max-width:600px;margin-inline:auto}.video-shell.portrait video{display:block;height:auto;aspect-ratio:4/5}.video-caption{display:flex;justify-content:space-between;gap:24px;margin:15px 0 0;color:var(--muted);font-size:12px}.video-caption strong{color:var(--ink);font-weight:500}.workflow{display:grid;grid-template-columns:1fr 1fr;gap:85px}.workflow-list{list-style:none;padding:0;margin:0;border-top:1px solid var(--line)}.workflow-list li{display:grid;grid-template-columns:38px 1fr;gap:20px;padding:26px 0;border-bottom:1px solid var(--line)}.step-no{font:11px var(--mono);color:var(--accent);padding-top:4px}.workflow-list h3{margin:0 0 10px}.workflow-list p{color:var(--muted);font-size:15px;line-height:1.6;margin:0}.architecture{background:var(--panel);border:1px solid var(--line);padding:32px;margin-top:36px}.architecture-title{font:11px var(--mono);color:var(--muted);text-transform:uppercase;letter-spacing:.1em;margin-bottom:22px}.architecture-line{display:flex;gap:16px;align-items:center;justify-content:space-between;font:13px var(--mono)}.architecture-line .arrow{color:var(--accent)}.note{border-left:2px solid var(--accent);padding-left:19px;font-size:13px;color:var(--muted);margin:30px 0 0}.feature-row{display:grid;grid-template-columns:220px 1fr 1fr;gap:45px;border-top:1px solid var(--line);padding:30px 0}.feature-row:last-child{border-bottom:1px solid var(--line)}.feature-row h3{font-size:19px;margin:0}.feature-row p{font-size:14px;color:var(--muted);margin:0}.feature-row code{color:var(--accent);font-size:12px}.release-panel{display:grid;grid-template-columns:1fr 1fr;gap:70px;background:#202d25;border:1px solid #415747;padding:45px}.release-panel p{color:#b9c6bc}.release-panel h2{font-size:40px}.release-facts{margin:0;padding:0;list-style:none;border-top:1px solid #415747}.release-facts li{display:flex;justify-content:space-between;gap:20px;padding:12px 0;border-bottom:1px solid #415747;font-size:13px}.release-facts strong{font-weight:500;color:#f0d19a}.community{display:grid;grid-template-columns:1fr 1fr;gap:100px}.text-link{display:inline-block;font-size:14px;margin-top:8px}.news-link{display:block;text-decoration:none;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:25px 0}.news-link time{display:block;font:11px var(--mono);color:var(--muted);margin-bottom:12px}.news-link strong{font-size:23px;font-weight:500;letter-spacing:-.03em;display:block}.news-link span{font-size:13px;color:var(--mint);display:block;margin-top:14px}.site-footer{border-top:1px solid var(--line);padding:42px 0 30px;color:var(--muted);font-size:12px}.footer-main{display:flex;justify-content:space-between;gap:40px;margin-bottom:30px}.footer-links{display:flex;gap:22px;flex-wrap:wrap}.credits{line-height:1.9;max-width:52ch}.credits strong{font-weight:500;color:var(--ink)}.footer-bottom{display:flex;justify-content:space-between;gap:30px;font-size:11px}.footer-bottom p{margin:0;max-width:60ch} +.page-header{padding:60px 0 40px;border-bottom:1px solid var(--line)}.page-header h1{font-size:clamp(38px,5vw,64px);max-width:18ch;margin-bottom:22px}.page-header .lead{max-width:65ch;color:var(--muted);font-size:18px}.page-content{padding:55px 0 90px}.prose{max-width:780px}.prose h2{font-size:30px;max-width:none;margin:42px 0 20px}.prose h3{font-size:22px;margin-top:30px}.prose p,.prose li{color:var(--muted)}.prose strong{color:var(--ink);font-weight:600}.prose ul,.prose ol{padding-left:24px}.prose li{margin-bottom:10px}.prose blockquote{border-left:2px solid var(--accent);margin:30px 0;padding-left:20px;color:var(--muted)}.codebox{position:relative;border:1px solid var(--line);background:#0b110e;padding:23px 20px;margin:22px 0}.codebox pre{padding-right:38px}.copy-button{position:absolute;top:12px;right:12px;border:1px solid var(--line);background:var(--panel);color:var(--muted);border-radius:2px;font-size:10px;padding:5px 8px;cursor:pointer}.copy-button:hover{color:var(--accent);border-color:var(--accent)}.copy-notice{font-size:11px;color:var(--mint);min-height:1.5em}.release-notice{padding:23px;border:1px solid #715d3e;background:#30291d;color:#e7c995;font-size:14px;margin:25px 0}.hash{overflow-wrap:anywhere;font-family:var(--mono);font-size:12px}.download-grid{display:grid;grid-template-columns:1.2fr .8fr;gap:80px}.download-summary{border-top:1px solid var(--line);margin-bottom:28px}.download-summary dt{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;padding-top:18px}.download-summary dd{margin:6px 0 0;padding-bottom:18px;border-bottom:1px solid var(--line);font-size:14px}.aside-note{font-size:13px;color:var(--muted)} +.docs{color-scheme:light;--bg:#f3f3ec;--panel:#e9ede5;--ink:#1c3024;--muted:#526357;--line:#cbd5c8;--accent:#795118;--mint:#3c7153}.docs .site-header{background:#19251e;color:#f3f3ec;border-color:#33443b}.docs .brand .prompt,.docs .cursor{color:#eac27b}.docs .site-header nav a{color:#c3cfc3}.docs .site-header nav a[aria-current=page]{color:#fff}.docs .nav-download{border-color:#566b5b}.docs-layout{display:grid;grid-template-columns:230px minmax(0,1fr);gap:65px;padding:50px 0 85px}.docs-nav{border-right:1px solid var(--line);padding-right:28px;font-size:13px}.docs-nav p{font:11px var(--mono);text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}.docs-nav ul{list-style:none;padding:0;margin:15px 0 28px}.docs-nav a{display:block;text-decoration:none;padding:7px 0;color:var(--muted)}.docs-nav a[aria-current=page]{color:var(--ink);font-weight:650}.docs article{max-width:780px}.docs h1{font-size:46px;max-width:none}.docs .prose p,.docs .prose li{color:var(--muted)}.docs .codebox{background:#e7ece3}.docs .copy-button{background:#f3f3ec}.docs .section-label{margin-bottom:15px}.docs .site-footer{background:#e9ede5}.docs .credits strong{color:#1c3024}.topic-links{display:grid;grid-template-columns:1fr 1fr;gap:0 35px;margin:30px 0}.topic-link{display:block;padding:18px 0;border-top:1px solid var(--line);text-decoration:none}.topic-link strong{display:block;margin-bottom:6px}.topic-link span{font-size:13px;color:var(--muted)} +@keyframes blink{50%{opacity:0}}@media(max-width:1000px){.hero{gap:45px}.header-inner{min-height:90px}nav{gap:17px}.brand{font-size:21px}.feature-row{grid-template-columns:170px 1fr 1fr;gap:25px}.release-panel{gap:35px}.architecture-line{font-size:12px;gap:10px}.docs-layout{grid-template-columns:190px minmax(0,1fr);gap:35px}} +@media(max-width:760px){.wrap{width:calc(100% - 40px)}body{font-size:16px}.header-inner{align-items:flex-start;flex-direction:column;gap:14px;padding:21px 0 17px}.brand{font-size:22px}nav{gap:14px;width:100%;font-size:12px;overflow-x:auto;padding:3px 0 7px}.nav-download{padding:5px 10px}.hero{display:block;padding:50px 0 48px}.hero h1{font-size:57px;max-width:11ch}.hero .lead{font-size:18px}.terminal{margin:45px 8px 0 0;box-shadow:8px 9px 0 #1b2821}.terminal-caption{text-align:left}.terminal-body{padding:25px}.strip{flex-wrap:wrap;gap:12px 20px;font-size:10px;padding:20px 0}.section{padding:50px 0}.section-head,.workflow,.community,.download-grid{display:block}.section-head{margin-bottom:25px}.section-head p{padding:0}.video-caption{display:block}.video-caption span{display:block;margin-top:5px}.workflow-list{margin-top:30px}.architecture{padding:25px}.architecture-line{flex-direction:column;align-items:flex-start;gap:10px}.architecture-line .arrow{transform:rotate(90deg);margin-left:6px}.feature-row{grid-template-columns:1fr;gap:12px;padding:24px 0}.feature-row code{font-size:11px}.release-panel{display:block;padding:28px}.release-panel h2{font-size:34px}.release-facts{margin-top:30px}.community>div+div{margin-top:35px}.footer-main,.footer-bottom{display:block}.footer-links{margin:25px 0}.footer-bottom p+p{margin-top:12px}.page-header{padding:40px 0 25px}.page-header h1{font-size:43px}.page-content{padding:30px 0 55px}.download-grid aside{margin-top:40px}.docs-layout{display:block;padding:30px 0 55px}.docs-nav{border-right:0;border-bottom:1px solid var(--line);padding:0 0 16px;margin-bottom:30px}.docs-nav ul{display:flex;flex-wrap:wrap;gap:3px 20px;margin:10px 0}.docs-nav a{padding:3px 0}.docs h1{font-size:37px}.topic-links{grid-template-columns:1fr}.codebox{padding:20px 16px}pre{font-size:12px}.codebox pre{padding-right:0;padding-top:20px}}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}.cursor{animation:none}} + +.profiles{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin:40px 0}.profile-card{min-width:0;border:1px solid var(--line,#31433b);padding:24px}.profile-card h2{font-size:1.6rem}.profile-card .actions{gap:10px}.profile-card details{margin-top:24px}.profile-card pre{font-size:.75rem;overflow:auto}@media(max-width:1000px){.profiles{grid-template-columns:1fr}.profile-card{padding:20px}} diff --git a/website/src/site.js b/website/src/site.js new file mode 100644 index 0000000..3abdeb4 --- /dev/null +++ b/website/src/site.js @@ -0,0 +1,12 @@ +"use strict"; +document.querySelectorAll(".codebox pre").forEach(function(pre){ + if(!navigator.clipboard || !window.isSecureContext) return; + var button=document.createElement("button"); + button.type="button";button.className="copy-button";button.textContent="Copy"; + button.setAttribute("aria-label","Copy command block"); + button.addEventListener("click",async function(){ + try{await navigator.clipboard.writeText(pre.textContent);button.textContent="Copied";setTimeout(function(){button.textContent="Copy"},1800)} + catch(error){button.textContent="Select to copy";setTimeout(function(){button.textContent="Copy"},2000)} + }); + pre.parentElement.appendChild(button); +}); diff --git a/website/test_release_catalog.py b/website/test_release_catalog.py new file mode 100644 index 0000000..ff182b1 --- /dev/null +++ b/website/test_release_catalog.py @@ -0,0 +1,73 @@ +#!/usr/bin/python3 +"""Catalog guards and rendered shell syntax; no signing or installation.""" +import copy +import json +from pathlib import Path +import runpy +import subprocess +import unittest + +BASE = Path(__file__).resolve().parent +catalog = runpy.run_path(str(BASE / "release_catalog.py")) + +class CatalogTests(unittest.TestCase): + def setUp(self): + self.meta = json.loads((BASE / "release.example.json").read_text()) + + def ready(self): + meta = copy.deepcopy(self.meta) + meta.update(state="ready", signing_fingerprint=catalog["FINGERPRINT"], + checksums_url="/downloads/TEST-SHA256SUMS", + checksums_signature_url="/downloads/TEST-SHA256SUMS.asc") + meta["source"] = dict(filename="TEST-source.tar.gz", url="/downloads/TEST-source.tar.gz", + signature_url="/downloads/TEST-source.tar.gz.asc", sha256="a"*64, size_bytes=1) + for index, profile in enumerate(meta["profiles"]): + name = "TEST-profile-" + str(index) + ".deb" + profile.update(filename=name, url="/downloads/"+name, + signature_url="/downloads/"+name+".asc", sha256="a"*64, size_bytes=1) + return meta + + def test_preparation_has_no_install_or_download_claim(self): + catalog["validate"](self.meta, False) + text = catalog["download"](self.meta) + self.assertIn("Release preparation", text) + self.assertNotIn("sudo apt install", text) + self.assertNotIn("Download .deb", text) + + def test_three_profile_shell_commands_are_valid_and_key_scoped(self): + meta = self.ready() + catalog["validate"](meta, True) + for profile in meta["profiles"]: + commands = catalog["apt_commands"](meta, profile) + result = subprocess.run(["bash", "-n"], input=commands, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Suites: " + profile["suite"], commands) + self.assertIn("Signed-By:", commands) + self.assertIn("primary_count", commands) + self.assertNotIn("trusted=yes", commands) + self.assertNotIn("apt-key", commands) + + def test_wrong_distribution_metadata_is_rejected(self): + self.meta["profiles"][1]["label"] = "Ubuntu 26.04" + with self.assertRaisesRegex(ValueError, "Profile identity"): + catalog["validate"](self.meta, False) + + def test_duplicate_profiles_are_rejected(self): + self.meta["profiles"][1] = self.meta["profiles"][0] + with self.assertRaisesRegex(ValueError, "Duplicate profile"): + catalog["validate"](self.meta, False) + + def test_extra_shell_characters_in_key_url_are_rejected(self): + meta = self.ready() + meta["signing_key_url"] = "/debian/key';echo-malicious" + with self.assertRaises(ValueError): + catalog["validate"](meta, True) + + def test_missing_artifact_checksum_is_rejected(self): + meta = self.ready() + meta["profiles"][2]["sha256"] = "" + with self.assertRaisesRegex(ValueError, "SHA-256"): + catalog["validate"](meta, True) + +if __name__ == "__main__": + unittest.main(verbosity=2)