Skip to content

generate-social-preview.py: vendor OFL font, pin deps, render from SVG, and add CI drift check for reproducible renders #1080

Description

@isaacschepp

Summary

scripts/generate-social-preview.py renders the committed .github/social-preview.png, but the render is environment-dependent and nothing in CI guards the asset. Four related problems, best fixed together. (For calibration: research confirms the overall approach is sound — GitHub's docs still recommend exactly 1280×640 PNG under 1 MB, and a committed asset + manual upload remains state of the art because GitHub has no API for the social preview, confirmed by GitHub staff in Sept 2025. This issue is about making the existing approach deterministic and guarded, not replacing it.)

1. System-font lookup makes the output non-reproducible — and the tracking pointer is stale

The script resolves fonts from the OS (arialbd.ttfDejaVuSans-Bold.ttfHelvetica.ttc), so re-rendering on a different OS produces different bytes. The in-code comment says vendoring a TTF is the fix and points to "issue #468 for follow-up" — but #468 is closed and never mentions font vendoring, so the follow-up is currently tracked nowhere. (This issue replaces that pointer; update the comment to reference this issue.)

Fix: vendor an OFL-licensed font (e.g. Inter or DejaVu Sans) under scripts/assets/ and load it by path. OFL 1.1 explicitly permits bundling and redistribution with software; the compliance requirement is committing the license text alongside — which fits the existing LICENSES/ directory (add LICENSES/OFL-1.1.txt).

2. macOS silently renders the wordmark non-bold (bug)

The bold candidate list falls back to /System/Library/Fonts/Helvetica.ttc, but ImageFont.truetype() on a .ttc without an index= loads face 0 — regular Helvetica, not bold. A maintainer regenerating on macOS would commit a visually wrong (non-bold wordmark) image with no error. Vendoring the font (point 1) eliminates this path entirely.

3. Unpinned dependencies

The docstring says pip install Pillow numpy with no versions; Pillow's text rasterization and zlib encoding can change across releases, which changes bytes. Modern practice for single-file scripts is PEP 723 inline metadata so uv run scripts/generate-social-preview.py is self-contained and pinned:

# /// script
# requires-python = ">=3.12"
# dependencies = ["Pillow==12.2.0", "numpy==2.4.0"]
# ///

(Current state verified 2026-06-10: with Pillow 12.2.0 on Windows, regeneration is byte-identical to the committed file, SHA256 48E7FEB8… — so determinism is achievable once font + versions are pinned. Pillow's PNG encoder embeds no timestamp chunk, so the remaining byte-stability risk is zlib/Pillow version skew; if that proves flaky across runners, the drift check can fall back to comparing decoded pixels, e.g. ImageChops.difference, instead of bytes.)

4. No CI drift check — despite the repo having a drift-check culture

drift-checks.yml, docs-drift.yml, and issue-templates-drift.yml already exist, but nothing verifies that the committed PNG still matches what the script + logo produce. Once points 1–3 make the render deterministic, add a job to drift-checks.yml:

  • regenerate the preview and git diff --exit-code .github/social-preview.png (or pixel-compare per the caveat above)

Optionally, a second check can detect upload drift: the currently uploaded preview is publicly fetchable via the repo's openGraphImageUrl (repository-images.githubusercontent.com/...); hash-comparing it to the committed file catches "committed a new preview but forgot to re-upload via Settings". (Verified in sync today.) If full automation is ever wanted, the only working paths drive the Settings UI with a browser — AnswerDotAI/gh-social-preview (Playwright, actively maintained) or mheap/github-social-image (Puppeteer) — probably not worth the complexity for this repo.

Bonus cleanup while in the file

  • Render the logo from public/logo.svg (clean vector, already in-repo) instead of chroma-keying the flat #2F2F2F backdrop out of public/logo.png — removes the LOGO_BG_COLOR/BG_THRESHOLD/BG_RAMP hack and gives crisper edges (resvg or cairosvg rasterize deterministically). For context, the 2026-mainstream OG tooling (satori/resvg via @vercel/og, Takumi) is HTML/CSS-to-image, but for a single static committed asset a Pillow script is perfectly defensible — no need to switch stacks.
  • The vertical-centering math uses ink bounding boxes (font.getbbox) with the default "la" anchor, so the text block sits ~bbox[1]px below true center and inter-line gaps vary with glyph extents. Cosmetic; worth fixing if the file is being touched anyway.
  • No Python lint runs in CI; if more Python accumulates in scripts/, add ruff to lint.yml.

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageIssue intake couldn't classify the Area — maintainer triage requiredtoolingInfrastructure, workflow, and developer tools

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions