No description
  • CSS 57.8%
  • Python 20.3%
  • Jinja 18.2%
  • JavaScript 3.3%
  • Dockerfile 0.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-04 17:26:00 +03:00
.forgejo/workflows chore(ci): update dependencies in site deployment 2026-05-07 18:28:51 +03:00
contrib/nginx docs: proofread README, nginx configs, Dockerfile, and CLI help 2026-09-04 17:08:08 +03:00
locales docs: update copyright years to 2023-2026 2026-09-01 14:27:36 +03:00
partials footer/sidebar: 'Errata API' / 'JSON API' link points at rdb.altlinux.org 2026-05-07 10:40:20 +00:00
static lang switcher: scope cookie to BASE_PATH and mark Secure on HTTPS 2026-05-19 08:09:50 +00:00
.dockerignore feat: Docker deployment on registry.altlinux.org/sisyphus/alt 2026-09-01 14:16:37 +03:00
.env.example referesh .env.example 2024-06-04 13:44:05 +03:00
.gitignore ui: BUGS as 5th severity chip for bug-only errata 2026-05-07 10:40:02 +00:00
branch.html.jinja feature(branch detail): year tabs above month chart 2026-05-07 10:40:20 +00:00
branches.html.jinja branches: show archived platforms in a second section + descriptive subtitles 2026-05-07 10:40:20 +00:00
bu.html.jinja deploy: support sub-path Pages deploys (<user>.<domain>/<repo>/) 2026-05-07 10:40:20 +00:00
Dockerfile docs: proofread README, nginx configs, Dockerfile, and CLI help 2026-09-04 17:08:08 +03:00
genpages.py docs: proofread README, nginx configs, Dockerfile, and CLI help 2026-09-04 17:08:08 +03:00
index.html.jinja stat tiles: drop the YYYY-MM label under 'This month' (redundant with the label itself) 2026-05-07 10:40:20 +00:00
index_year.html.jinja deploy: support sub-path Pages deploys (<user>.<domain>/<repo>/) 2026-05-07 10:40:20 +00:00
LICENSE.md add LICENSE.md file 2024-06-04 16:46:55 +03:00
pu.html.jinja deploy: support sub-path Pages deploys (<user>.<domain>/<repo>/) 2026-05-07 10:40:20 +00:00
README.md docs: proofread README, nginx configs, Dockerfile, and CLI help 2026-09-04 17:08:08 +03:00
ruff.toml add ruff.toml config for ruff 2024-06-04 17:10:44 +03:00
VERSION chore: bump version to 4.0.0 2026-09-04 17:26:00 +03:00

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

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 ~7585% 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 .html extensions (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 _redirects file at the site root maps clean URLs (/ALT-PU-…, /index/…, /branches/…) to the underlying .html files 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:

  1. Fetch the full list of errata IDs from GET /api/errata/ids.
  2. Split PU and BU IDs into separate chunk lists, distribute across a multiprocessing pool (PU chunks go to one worker function, BU chunks to another).
  3. Each worker POSTs its chunk of IDs to the matching endpoint — /api/errata/packages_updates for PUs, /api/errata/branches_updates for BUs.
  4. API responses are cached on disk (gzipped JSON keyed by SHA-256 of URL + sorted request body) — subsequent runs only re-fetch changed chunks.
  5. Workers render each erratum through Jinja2 templates (localized via gettext), write the HTML pages, and return partial search-index slices.
  6. The main process merges worker results, renders index/year/branch pages, writes search-index JSON shards, sitemaps, robots.txt, and copies in static assets.
  7. 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.mo at build time (optional at runtime if .mo files are pre-built).

  • Optional, for -c br / -c zstd — the default -c gz needs 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:

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's gunzip fallback 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:

  1. Create locales/<lang>/LC_MESSAGES/base.po (and compile it to .mo).
  2. Add the language to the LOCALES dictionary and the OG_LOCALES mapping in genpages.py.
  3. Rebuild with --localize — the flag enables every locale present in LOCALES, 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_module picks 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 -c default) plus a 0-byte stub, served via gzip_static always;
    • gunzip on; — needs --with-http_gzip_static_module and --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 br or -c zstd instead, switch to the matching commented-out block in the sample config — read its comment first, since brotli/zstd have no equivalent to gzip's universal gunzip fallback for non-supporting clients.
  • Symlink-friendly: errata pages on disk are served without a file extension (the default default_type text/html applies).
  • 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 master or deploy-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:

  1. Install dependencies (apt-get install in an alt:sisyphus container).
  2. Checkout the repository.
  3. Compile translation catalogs.
  4. 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).
  5. Run genpages.py -l -i --pages-mode --canonical-url … -d site/.
  6. Deploy site/ to the pages branch via actions/deploy-pages.

Setup:

  1. In your Forgejo repository settings, enable Pages and set the source branch to pages.
  2. Create a repository secret TOKEN with read/write repository permissions (for the deploy-pages action).
  3. 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 (not conf.d/), so the image places the site config there.
  • The nginx-accept_language module 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's Accept-Encoding (nginx decompresses on the fly via gunzip). 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-After headers on 429.
  • Rate-limit ramp-up: Each worker adds a random 03 second delay before its first API call so the pool does not hammer the server all at once.
  • Page compression, single-format by design: _CODECS maps each supported format (gz/br/zstd) to its file suffix, level range, and (optional) compress function; -c picks 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. Naming br/zstd without the matching optional package installed is a hard error, not a silent fallback to gz. See Compression formats for measured size/build-time/ client-decompression trade-offs and the recommendation. xz isn't in _CODECS at all — there's no Content-Encoding: xz in the IANA HTTP content-coding registry, so no browser could ever request it, and nginx has no xz_static module 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 ~1520 minutes to ~23 (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 every POOL_BATCH chunks (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 © 20232026 BaseALT Ltd.

The static assets in static/main/css/patternfly*.css are derived from PatternFly and are covered by the MIT license.