- CSS 57.8%
- Python 20.3%
- Jinja 18.2%
- JavaScript 3.3%
- Dockerfile 0.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| contrib/nginx | ||
| locales | ||
| partials | ||
| static | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| branch.html.jinja | ||
| branches.html.jinja | ||
| bu.html.jinja | ||
| Dockerfile | ||
| genpages.py | ||
| index.html.jinja | ||
| index_year.html.jinja | ||
| LICENSE.md | ||
| pu.html.jinja | ||
| README.md | ||
| ruff.toml | ||
| VERSION | ||
errata-genpages
Static site generator for ALT Linux errata bulletins. Fetches errata data from the RDB API, renders localized HTML pages (English and Russian) from Jinja2 templates, and writes a fully self-contained static site suitable for nginx or Forgejo Pages.
Table of contents
- What it produces
- Architecture
- Requirements
- Quick start
- CLI reference
- Environment variables
- Localization
- Deployment
- Directory layout
- Development
- License
What it produces
A static tree at the destination directory (-d):
<dest>/
├── en/ # English pages
│ ├── ALT-PU-2025-1234 # 0-byte stub for nginx's try_files (default mode);
│ │ # the actual content lives in the compressed twin
│ │ # below, served by whichever of gzip_static/
│ │ # brotli_static/zstd_static matches -c. In
│ │ # --pages-mode / -C this is a plain HTML file instead.
│ ├── ALT-PU-2025-1234.gz # Pre-compressed page content — one twin, whichever
│ │ # extension matches -c/--compress (.gz/.br/.zst)
│ ├── ALT-PU-2025-1234-1 # Versioned alias — relative symlink to the
│ │ # unversioned stub (a plain copy in pages-mode)
│ └── ALT-PU-2025-1234-1.gz # compressed twin of the versioned page
│ ├── ALT-BU-2025-0567 # Branch update page
│ ├── index/
│ │ ├── index.html # Root index — stats, recent errata, year archive
│ │ ├── 2025 # Year archive page (pages-mode: 2025.html)
│ │ ├── 2024
│ │ └── search/ → ../../index/search/ # Symlink to shared JSON search shards
│ └── branches/
│ ├── index.html # Branches overview (all active + archived)
│ └── <branch>/
│ └── index.html # Per-branch detail — charts, top packages, recent
├── ru/ # Russian pages (same structure as en/)
├── index/search/ # JSON search index shards (locale-agnostic)
│ ├── id.json # All known errata IDs with metadata
│ ├── cve.json # CVE → errata ID mapping
│ └── package.json # Package name → errata ID mapping
│ # (each also has a compressed twin in default mode)
├── static/ # CSS, JS, fonts, images (cache-busted via version param)
├── sitemap.xml # Sitemap index
├── sitemap-<year>.xml # Per-year URL sitemaps
├── sitemap-pages.xml # Landing-page sitemap
└── robots.txt
By default, every generated page is written pre-compressed as gzip
(<name>.gz) plus a 0-byte stub at <name> itself. The stub exists only
so nginx's try_files checks have something to stat() — gzip_static
resolves <uri>.gz on its own and never reads the stub's content. This
saves ~75–85% of the on-disk size for the HTML/JSON/SVG/XML output.
Use -c br or -c zstd instead of the gz default for a better ratio
(brotli/zstd typically beat gzip by another ~10-20% on this kind of
text). Only one format is ever written at a time: an earlier revision
wrote gzip+brotli+zstd side by side automatically whenever the optional
packages were installed, which on the real corpus came out to 2.7 GB
versus 1.1 GB for gzip alone — three parallel copies cost more than any
one of them saves, quietly undoing most of the disk-space point of
precompressing pages in the first place. Picking br or zstd also means
giving up gzip's universal gunzip decompression fallback for clients
that don't negotiate that format — see the nginx deployment
section before switching. -C/--no-compress (or --pages-mode) gets
plain uncompressed files instead. See CLI reference for
the full set of flags, including per-format compression levels.
In --pages-mode (Forgejo Pages):
- Pages use
.htmlextensions (e.g.,ALT-PU-2025-1234.html). - No pre-compression: every page is a plain file, since static hosts serve them as-is.
- Unversioned aliases are plain file copies instead of symlinks.
- A
_redirectsfile at the site root maps clean URLs (/ALT-PU-…,/index/…,/branches/…) to the underlying.htmlfiles on disk.
Architecture
┌──────────────────┐ HTTP POST ┌───────────────────────┐
│ rdb.altlinux.org │◄───────────────────►│ genpages.py │
│ (Errata API) │ (JSON) │ │
└──────────────────┘ │ ┌─────────────────┐ │
│ │ API cache │ │
│ │ ~/.cache/errata-│ │
│ │ genpages/api/ │ │
│ └─────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ Jinja2 templates│ │
│ │ *.html.jinja │ │
│ └─────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ gettext locales │ │
│ │ locales/{en,ru} │ │
│ └─────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ Process pool │ │
│ │ (multiprocessing│ │
│ │ fork workers) │ │
│ └─────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ compress+stub │ │
│ │ <name>.<ext> │ │
│ │ (-c: gz/br/ │ │
│ │ zstd) + 0-byte │ │
│ │ stub, atomic │ │
│ │ swap │ │
│ └─────────────────┘ │
└───────────┬───────────┘
│ writes
▼
┌───────────────────────┐
│ Static site tree │
│ (<dest>/) │
└───────────────────────┘
Data flow:
- Fetch the full list of errata IDs from
GET /api/errata/ids. - Split PU and BU IDs into separate chunk lists, distribute across a
multiprocessingpool (PU chunks go to one worker function, BU chunks to another). - Each worker POSTs its chunk of IDs to the matching endpoint —
/api/errata/packages_updatesfor PUs,/api/errata/branches_updatesfor BUs. - API responses are cached on disk (gzipped JSON keyed by SHA-256 of URL + sorted request body) — subsequent runs only re-fetch changed chunks.
- Workers render each erratum through Jinja2 templates (localized via
gettext), write the HTML pages, and return partial search-index slices. - The main process merges worker results, renders index/year/branch pages,
writes search-index JSON shards, sitemaps,
robots.txt, and copies in static assets. - The temporary build directory is atomically swapped into the destination
directory via
renameat2(AT_FDCWD, …, RENAME_EXCHANGE).
Requirements
-
Python 3.9+ — no dependencies beyond the system-provided modules listed below.
-
System packages (ALT Linux package names):
apt-get install python3-module-jinja2 \ python3-module-requests \ python3-module-python-dotenv \ python3-module-more-itertools \ python3-module-alt_releases_matrix \ gettext-tools -
gettext for compiling
.po→.moat build time (optional at runtime if.mofiles are pre-built). -
Optional, for
-c br/-c zstd— the default-c gzneeds nothing beyond stdlib; installing either of these just makes it a legal choice for-c(see CLI reference):apt-get install python3-module-brotli python3-module-zstandard
For serving:
- nginx with
ngx_http_accept_language_module(for automatic locale detection on short URLs). Seecontrib/nginx/errata.conf.sample.
Which nginx modules exist, per compression format — verified against the
stock nginx in this repo's Docker image (ALT Sisyphus, nginx 1.30.4) and
the Debian/Ubuntu packaging: the static module is what actually serves
pre-compressed pages (*_filter is for on-the-fly compression and not
needed here):
| Format | *_static module |
Availability |
|---|---|---|
gz |
gzip_static (+ gunzip) |
In the box — compiled into stock nginx (--with-http_gzip_static_module, --with-http_gunzip_module; check with nginx -V). Served by both configs in this repo. |
br |
brotli_static (ngx_brotli) |
Not in the box. Debian/Ubuntu package it (libnginx-mod-http-brotli-static); ALT Linux does not — build ngx_brotli yourself (see the sample config's Option B). Not installed by this repo's Dockerfile, so the Docker image serves gz only. |
zstd |
zstd_static (3rd-party) |
Not in the box anywhere mainstream — no distro packages it; build tokers/zstd-nginx-module yourself (see Option C). Same story for the Docker image. |
Quick start
# 1. Clone and set up
git clone https://github.com/altlinux/errata-genpages.git
cd errata-genpages
# 2. Configure (optional — defaults point to the production API)
# cp .env.example .env
# Edit .env if you need a different API endpoint:
# BASE_API_ADDRESS="https://rdb.altlinux.org"
# 3. Compile translations (required — the script loads pre-built .mo catalogs
# and does not run msgfmt itself; CI does this as a separate step)
msgfmt locales/en/LC_MESSAGES/base.po -o locales/en/LC_MESSAGES/base.mo
msgfmt locales/ru/LC_MESSAGES/base.po -o locales/ru/LC_MESSAGES/base.mo
# 4. Minimal build (English-only, no index pages) — step 5 below is the
# fuller variant; each run atomically replaces the previous output
python3 genpages.py -d site/
# 5. Build with localization and index pages for local preview
python3 genpages.py -l -i -d site/
# 6. Build in pages-mode (for Forgejo Pages deployment)
python3 genpages.py -l -i --pages-mode \
--canonical-url https://your-org.altlinux.team/errata-genpages \
-d site/
Output appears in site/. Serve it with any static file server:
python3 -m http.server -d site/ 8000
CLI reference
usage: genpages.py [-h] [-d DESTINATION] [-H HOMEPAGE] [-l] [-i] [-S]
[-c {gz,br,zstd}] [-C]
[--gzip-level 1-9] [--brotli-level 0-11]
[--zstd-level 1-22]
[--cache-dir CACHE_DIR] [--no-cache] [--no-cache-prune]
[--min-free-percent MIN_FREE_PERCENT] [--pages-mode]
[--canonical-url CANONICAL_URL]
| Flag | Description |
|---|---|
-d, --destination DIR |
Output directory (default: .). Created if it doesn't exist; if it does, contents are atomically swapped with the new build via renameat2(2). |
-H, --homepage URL |
Homepage base URL passed to templates as homepage_url (default: /). |
-l, --localize |
Generate both English and Russian pages. Without this flag, only English is produced. |
-i, --index |
Generate index pages (/index/, /branches/, yearly archives, per-branch detail pages), search JSON shards, sitemaps, and robots.txt. |
-S, --no-static |
Skip copying static assets (static/ directory) to the output. |
-c, --compress {gz,br,zstd} |
Single format to pre-compress pages into (default: gz). -c=br, -c br, and -cbr all work. Naming br/zstd without the matching optional package installed is a hard error rather than a silent fallback to gz. No effect in --pages-mode. |
-C, --no-compress |
Write plain uncompressed pages instead of pre-compressing them. In --pages-mode pages are always plain, so this is redundant there. |
--gzip-level N |
gzip level, 1-9 (default: 9, max). Only used when -c gz. |
--brotli-level N |
Brotli quality, 0-11 (default: 9 — best size/CPU tradeoff; see Compression formats). Only used when -c br. |
--zstd-level N |
Zstandard level, 1-22 (default: 16 — the "ultra" range gives no gain on KB-sized HTML; see Compression formats). Only used when -c zstd. |
--cache-dir DIR |
API response cache directory (default: $XDG_CACHE_HOME/errata-genpages/api or ~/.cache/errata-genpages/api). |
--no-cache |
Bypass the on-disk API cache and always fetch fresh data. |
--no-cache-prune |
Keep API-cache entries this run didn't need, instead of sweeping them at the end (see On-disk API cache below). |
--min-free-percent N |
Log a WARNING at the end of the run if free disk space at the destination falls below N percent (default: 10). |
--pages-mode |
Emit .html file extensions, copy (instead of symlink) unversioned aliases, and generate a _redirects file for Forgejo Pages / Codeberg Pages routing. |
--canonical-url URL |
Public base URL used in <link rel="canonical">, og:url, sitemap entries, and the deploy sub-path (base_path). Default: https://errata.altlinux.org. |
-G/--no-gzip still works as a deprecated alias for -C/--no-compress
(with a warning logged), so existing cron invocations don't break — switch
them over when convenient.
Only one compression format is ever active at a time. An earlier
revision wrote gzip+brotli+zstd side by side automatically whenever the
optional packages happened to be installed; measured against the real
corpus that came out to 2.7 GB versus 1.1 GB for gzip alone — three
parallel copies cost more than any one of them saves, which defeats the
point of precompressing pages in the first place. -c now always picks
exactly one.
Typical invocation patterns
Full production build (localized, with indexes, swapped into a live directory):
python3 genpages.py -l -i -d /var/www/html/errata/
Forgejo Pages CI build (pages-mode, cache enabled):
python3 genpages.py -l -i --pages-mode \
--canonical-url "https://${OWNER}.altlinux.team/${REPO}" \
-d site/
Incremental preview (English-only, no indexes, inline output):
python3 genpages.py -d preview/
Brotli instead of the gzip default (better ratio, same single-format
footprint — see What it produces for the
compatibility trade-off before switching, and update
contrib/nginx/errata.conf.sample to match):
python3 genpages.py -l -i -d /var/www/html/errata/ -c br
Compression formats
Three codecs are supported, picked one at a time with -c. All three
produce byte-identical output (verified by sha256 across separate runs);
they differ in on-disk size, build time, and — most importantly for a
static site — client-side decompression speed, since the browser does
that work on every page load.
Measured on this project's real corpus (~256k pages, full -l -i build):
| Format | Default level | Site size on disk | Build time |
|---|---|---|---|
gz |
9 | 1.1 GB | ~3:24 |
br |
9 | 895 MB | ~8:14 |
zstd |
16 | 946 MB | ~6:03 |
Per-page micro-benchmark (median, one representative ~10 KB errata page; also a ~2 MB JSON shard for contrast):
| Format | Compress (build side) | Decompress (client side) | Page size | Shard size |
|---|---|---|---|---|
gz L9 |
0.5 ms | 62 µs | 4.2 KB | 153 KB |
br L9 |
5.5 ms | 37 µs | 2.2 KB | 41 KB |
zstd L16 |
16 ms | 23 µs | 2.1 KB | 24 KB |
Reading the trade-offs:
gz(default) — fastest to build, universally supported, and the only one with a mainstream on-the-fly decompression fallback in nginx (gunzip). Slowest client-side decompression of the three, but at ~60 µs per page that is far below any perceptible threshold. The safe default.br— smallest HTML (brotli's strength) and fast enough to build. Best choice when minimizing download size matters most. Loses gzip'sgunzipfallback for non-supporting clients — see the nginx warning.zstd— fastest client-side decompression and smallest on large files (the JSON shards), but slowest to build and with narrower browser support. Niche choice here.
The default levels were picked for maximum compression without excessive CPU cost: gzip 9 (its max), brotli 9 (level 11 buys little extra ratio at several times the build cost), zstd 16 (the "ultra" range 20-22 gives no measurable gain on KB-sized HTML — it targets large inputs with long-range matches).
Why no xz? Python's stdlib lzma could produce it trivially, but xz is
not a valid HTTP content coding: there is no Content-Encoding: xz in the IANA
registry, no browser sends or accepts it, and nginx has no xz_static
module. Precompressing pages with xz would therefore never be served to a
visitor — it only exists as a file format on disk, so it is not offered.
Environment variables
All are optional — defaults target the production ALT Linux infrastructure.
| Variable | Default | Description |
|---|---|---|
BASE_API_ADDRESS |
https://rdb.altlinux.org |
Base URL for the RDB API. |
DATEFMT |
%Y-%m-%d |
strftime format for dates on rendered pages. |
STATIC_ROOT |
/static (or {base_path}/static in pages-mode) |
URL path prefix for static assets. |
XDG_CACHE_HOME |
~/.cache |
Used to derive the default API cache directory. |
API endpoints are derived from BASE_API_ADDRESS:
{BASE_API_ADDRESS}/api/errata/ids{BASE_API_ADDRESS}/api/errata/packages_updates{BASE_API_ADDRESS}/api/errata/branches_updates
External links to packages and bugzilla use upstream defaults from
alt_releases_matrix and are not affected by environment variables.
Localization
The site supports English (en) and Russian (ru). Translations are managed
through standard gettext .po files:
locales/
├── en/
│ └── LC_MESSAGES/
│ ├── base.po # Source strings (msgid in English)
│ └── base.mo # Compiled catalog
└── ru/
└── LC_MESSAGES/
├── base.po # Russian translations
└── base.mo # Compiled catalog
The script loads pre-built .mo catalogs via gettext.translation() and does
not run msgfmt itself — compile the catalogs first (see Quick start), as the
CI workflow does in a separate step. A missing or stale .mo will surface as
a catalog-loading error at startup.
To add a new language:
- Create
locales/<lang>/LC_MESSAGES/base.po(and compile it to.mo). - Add the language to the
LOCALESdictionary and theOG_LOCALESmapping ingenpages.py. - Rebuild with
--localize— the flag enables every locale present inLOCALES, so the new one is picked up automatically.
Deployment
nginx
A complete nginx configuration sample is provided at
contrib/nginx/errata.conf.sample.
Key features of the configuration:
- Accept-Language routing: the
ngx_http_accept_language_modulepicks the user's preferred locale and rewrites short URLs (/ALT-PU-…,/index/…,/branches/…) to/en/…or/ru/…. - Far-future caching for versioned static assets (
/static/→ 365 days). - Short revalidation for HTML pages (
max-age=300, i.e. 5 minutes) so a stale page is never served for long between the roughly 6-hour rebuilds. - Pre-compressed pages: genpages.py writes every page as
<name>.gz(the-cdefault) plus a 0-byte stub, served viagzip_static always;gunzip on;— needs--with-http_gzip_static_moduleand--with-http_gunzip_module, which stock nginx.org and most distro packages have.gzip on;remains as a fallback for hand-placed assets. If you build the site with-c bror-c zstdinstead, switch to the matching commented-out block in the sample config — read its comment first, since brotli/zstd have no equivalent to gzip's universalgunzipfallback for non-supporting clients.
- Symlink-friendly: errata pages on disk are served without a file extension
(the default
default_type text/htmlapplies). - HTTPS template is included (commented) for certbot-managed certificates.
Deployment steps:
# 1. Install nginx with the accept-language module
apt-get install nginx nginx-accept_language
# 2. Copy and adapt the sample config
cp contrib/nginx/errata.conf.sample /etc/nginx/sites-available/errata.conf
# Edit server_name, root, and SSL paths as needed.
# 3. Pick the compression block that matches how the site was built —
# exactly one of the three blocks must be uncommented (the sample ships
# with "Option A: gz" active, matching genpages.py's default):
# -c gz (default) -> leave Option A as-is
# -c br -> comment out Option A, uncomment Option B,
# install ngx_brotli first (see its comment)
# -c zstd -> comment out Option A, uncomment Option C,
# build the third-party module first
# 4. Enable and start
ln -s /etc/nginx/sites-available/errata.conf /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
Verifying after deploy: request any page and check it is not empty —
the failure mode of a wrong (or missing) compression block is an HTTP 200
with Content-Length: 0, i.e. a blank white page with no error anywhere:
curl -sI http://localhost/ALT-PU-2025-1 | head -3 # expect Content-Length > 0
A mismatched block only breaks clients that don't send the matching
Accept-Encoding, so spot-check with a client that sends none at all
(curl -H 'Accept-Encoding: identity') in addition to your browser.
Forgejo Pages
The repository includes a CI workflow at
.forgejo/workflows/deploy-site.yaml
that builds and publishes the site automatically.
Triggers:
- Push to
masterordeploy-prep, when the push touches site sources (genpages.py, templates,static/,locales/,VERSION) or the workflow itself. - Scheduled every 6 hours (picks up newly published errata).
- Manual dispatch (
workflow_dispatch).
Workflow steps:
- Install dependencies (
apt-get installin analt:sisyphuscontainer). - Checkout the repository.
- Compile translation catalogs.
- Restore API response cache (actions/cache — cuts build time from ~15 min to ~2 min; the end-of-run prune also keeps this cache from growing unbounded).
- Run
genpages.py -l -i --pages-mode --canonical-url … -d site/. - Deploy
site/to thepagesbranch viaactions/deploy-pages.
Setup:
- In your Forgejo repository settings, enable Pages and set the source
branch to
pages. - Create a repository secret
TOKENwith read/write repository permissions (for thedeploy-pagesaction). - Push to
master— the workflow runs and publishes the site.
The canonical URL is automatically constructed from the repository owner and
name (${GITHUB_REPOSITORY}, the actions-compatible variable name Forgejo
also sets): https://<owner>.altlinux.team/<repo>.
Docker
A container image based on registry.altlinux.org/sisyphus/alt:latest
for local testing or containerised deployment. It serves gzip-compressed
sites only (the base ALT nginx package ships no brotli/zstd static module),
so build with the -c gz default — a site built with -c br or -c zstd
will serve as blank pages in this image.
# 1. Build the site (as usual; gzip is the default, so -c can be omitted)
python3 genpages.py -l -i -d site/
# 2. Ensure the generated files are world-readable (the nginx worker runs
# as an unprivileged user and must be able to read the mounted volume)
chmod -R a+rX site/
# 3. Build the Docker image
docker build -t errata-genpages .
# 4. Run with the generated site mounted read-only
docker run -v ./site:/var/www/html:ro -p 8080:80 errata-genpages
Notes:
- The ALT nginx package includes
/etc/nginx/sites-enabled.d/*.conf(notconf.d/), so the image places the site config there. - The
nginx-accept_languagemodule is installed and enabled in the image; the Docker config itself does not require it (map-based detection). - nginx runs in the foreground, with access/error logs streamed to stdout.
- Blank page check: a healthy container returns full content at
http://localhost:8080/regardless of the client'sAccept-Encoding(nginx decompresses on the fly viagunzip). An empty 200 response is the signature of a site built with-c br/-c zstd— this image serves gzip sites only, see nginx.
The Docker config (contrib/nginx/errata-docker.conf) uses a map-based
Accept-Language detection so no third-party nginx modules are required.
Open http://localhost:8080 — the language is auto-detected from your
browser's Accept-Language header, with a cookie-based override from the
RU/EN switcher.
Directory layout
errata-genpages/
├── genpages.py # Main script — entry point
├── .env.example # Environment variable reference
├── VERSION # Semantic version (used for static asset cache-busting)
├── ruff.toml # Python linter configuration
├── LICENSE.md # GNU AGPL-3.0
│
├── *.html.jinja # Jinja2 page templates
│ ├── index.html.jinja # Root index (hero + stats + recent + year archive)
│ ├── index_year.html.jinja # Year archive (/index/<year>)
│ ├── branches.html.jinja # Branches overview (/branches/)
│ ├── branch.html.jinja # Per-branch detail (/branches/<name>/)
│ ├── pu.html.jinja # Package update page (ALT-PU-…)
│ └── bu.html.jinja # Branch update page (ALT-BU-…)
│
├── partials/ # Shared template fragments
│ ├── chrome_topbar.html.jinja # Navigation bar
│ ├── chrome_footer.html.jinja # Footer
│ ├── chrome_logo.html.jinja # ALT Linux logo
│ └── search_form.html.jinja # Search input (ID / CVE / package)
│
├── locales/ # gettext translation catalogs
│ ├── en/LC_MESSAGES/{base.po,base.mo}
│ └── ru/LC_MESSAGES/{base.po,base.mo}
│
├── static/ # Frontend assets
│ └── main/
│ ├── css/
│ │ ├── base.css # Main stylesheet
│ │ ├── table.css # Table styles
│ │ ├── patternfly.min.css # PatternFly base
│ │ └── patternfly-addons.css
│ ├── js/
│ │ ├── search.js # Client-side search (fuzzy, CVE, package)
│ │ ├── errata-filter.js # Severity/branch filtering on tables
│ │ ├── errata-page.js # Single-errata page interactivity
│ │ └── theme-lang.js # Dark/light theme toggle + language switcher
│ └── image/
│ ├── favicon.svg
│ ├── favicon.ico
│ └── altlinux-logo.svg
│
├── contrib/
│ └── nginx/
│ ├── errata.conf.sample # nginx server configuration (production)
│ └── errata-docker.conf # nginx config for the Docker image
├── Dockerfile # sisyphus/alt container for testing/deployment
├── .dockerignore
│
└── .forgejo/workflows/
└── deploy-site.yaml # CI/CD pipeline for Forgejo Pages
Development
Running locally
# Install dev dependencies
apt-get install python3-module-jinja2 python3-module-requests \
python3-module-python-dotenv python3-module-more-itertools \
python3-module-alt_releases_matrix gettext-tools
# Compile translations
msgfmt locales/en/LC_MESSAGES/base.po -o locales/en/LC_MESSAGES/base.mo
msgfmt locales/ru/LC_MESSAGES/base.po -o locales/ru/LC_MESSAGES/base.mo
# Build and serve
python3 genpages.py -l -i -d site/
python3 -m http.server -d site/ 8000
Linting
apt-get install python3-module-ruff
ruff check genpages.py
Testing against a local/offline API
Set BASE_API_ADDRESS to a mock server:
BASE_API_ADDRESS=http://localhost:5000 python3 genpages.py -l -i --no-cache -d site/
Key design decisions
- Atomic swaps: The build happens in a temporary directory. Once complete,
the destination is atomically exchanged with it via
renameat2(RENAME_EXCHANGE)(Linux). The live directory is never left in a partially-populated state. - Multiprocessing with fork: Workers inherit the Jinja2 environment, templates, and configuration from the parent process. Only the ID chunks to render are passed via the pool — no serialization overhead for large template objects.
- API retry logic: The RDB API can drop connections under load. The script
retries transport errors (connection errors, chunked-encoding errors,
timeouts) and HTTP 429/5xx with exponential backoff (up to 8 attempts),
honoring numeric
Retry-Afterheaders on 429. - Rate-limit ramp-up: Each worker adds a random 0–3 second delay before its first API call so the pool does not hammer the server all at once.
- Page compression, single-format by design:
_CODECSmaps each supported format (gz/br/zstd) to its file suffix, level range, and (optional) compress function;-cpicks exactly one. An earlier revision auto-selected every installed format at once (gzip + brotli + zstd together, all optional ones simply joining a "why not" default); measured on the real ~256k-page corpus that came out to 2.7 GB versus 1.1 GB for gzip alone — three parallel copies cost more than any one of them saves, which quietly ate most of the disk-space win pre-compression exists for. Namingbr/zstdwithout the matching optional package installed is a hard error, not a silent fallback togz. See Compression formats for measured size/build-time/ client-decompression trade-offs and the recommendation. xz isn't in_CODECSat all — there's noContent-Encoding: xzin the IANA HTTP content-coding registry, so no browser could ever request it, and nginx has noxz_staticmodule to serve it. - On-disk API cache: POST responses are cached as gzipped JSON keyed by
SHA-256 of URL + sorted request body. Cache hits drop a full rebuild from
~15–20 minutes to ~2–3 (per the CI workflow comments). Chunk boundaries are
fixed-size windows over the current sorted ID set, so stable chunks keep
their key run to run, but the newest not-yet-full chunk changes membership
every time new errata land — its old key is never looked up again. To stop
those orphaned entries accumulating, each hit touches the file's mtime and,
at the end of a run, entries not touched since the run started (plus
.tmp.<pid>debris from crashed runs older than an hour) are swept. The sweep is content-addressed, so it is safe even with--no-cache; disable it with--no-cache-prune. - Performance optimizations: data models use
__slots__instead of@dataclass, cutting per-object memory for the ~600k erratum objects; branch- month aggregates are pre-computed during the single ID pass (avoiding O(N×B) scans); sitemap XML is streamed directly to disk; HTML minification uses a pre-compiled regex; the render pool is rebuilt everyPOOL_BATCHchunks (WORKERS * 4) because forked workers' memory grows across chunks and is not always returned to the OS, so fresh workers keep peak memory bounded.
License
GNU Affero General Public License v3.0 — Copyright © 2023–2026 BaseALT Ltd.
The static assets in static/main/css/patternfly*.css are derived from
PatternFly and are covered by the MIT license.