CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
extension / lint (push) Successful in 21s
CI and images / frontend-build (push) Successful in 21s
CI and images / backend-lint-and-test (push) Successful in 31s
CI and images / integration (push) Successful in 2m10s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 1m51s
CI and images / smoke-web (push) Successful in 57s
CI and images / promote (push) Skipped
Operator, 2026-09-23: *"tighten the gate so :dev can't publish on red tests"*, then *"I don't want failing builds to publish anywhere going forward."* Run 7348 is the worked example. The backend unit lane went red on `2f8f0bc` and `build-web` pushed `:dev` in the same minute, because the lanes and the build were SEPARATE WORKFLOWS on the same push trigger. Neither could see the other's verdict. `:dev` was a "it built" signal, never a "it passed" one, and nothing about that was visible from either run. Two workflows cannot express the gate. A `needs:` edge only exists inside one graph. So `ci.yml`'s five lanes move into `build.yml` and `ci.yml` is deleted; `sign-extension`, `build-web` and `build-agent` now need all five. Nothing here is a new mechanism — it is the same edge that has gated `promote` since milestone 362 step 4, and it keeps that step's hardest-won property: **not running is not the same as passing.** `needs` treats a SKIPPED dependency as unsatisfied, so a lane that silently skips itself blocks the publish exactly as a failing one does. Run 5290 is why that is worth stating. Scope, said plainly rather than implied: - Gated: every image tag (`:dev`, `:latest`, `:c-<sha>`), the weekly base refresh, and the `ext-<version>` signed-XPI release asset — `sign-extension` publishes too, so it is gated with the rest. - Not gated, deliberately: `extension.yml` publishes nothing, and `release.yml` runs on a `v*` tag, generates notes rather than an artifact, and its commit already went through main's gated build. - `pull_request` (Renovate bumps into `dev`) comes across with the lanes. Its runs are the lanes and nothing else, via an `if:` on each publishing job rather than an inference from the `needs` chain. The cost, accepted knowingly: this workflow queues per branch and never cancels, so on two pushes in quick succession the second's lint feedback waits out the first's build. A slower red beats a fast red that ships. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
156 lines
6.1 KiB
Python
156 lines
6.1 KiB
Python
"""Prove a freshly built image can still do the things its OS packages provide.
|
|
|
|
Run INSIDE the image, not against the source tree. That distinction is the
|
|
entire reason this file exists.
|
|
|
|
`build.yml`'s lanes run on `ci-python:3.14` and install `requirements.txt`. A base
|
|
refresh changes neither, so all five lanes stay green through a base bump that
|
|
breaks the product. What a refresh actually re-resolves is this, from the
|
|
Dockerfile:
|
|
|
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
ffmpeg unar libpq5 postgresql-client zstd megatools \
|
|
libjpeg62-turbo libwebp7 libpng16-16 ca-certificates
|
|
|
|
Unpinned, every build. Nothing else in this repo looks at it.
|
|
|
|
So the checks below run the APPLICATION'S OWN code — `Thumbnailer`, which needs
|
|
no database and no app context — against whatever Pillow and ffmpeg have
|
|
become. `ffmpeg -version` exiting 0 would pass while a codec removal or an
|
|
soname bump broke every thumbnail in the library; producing a thumbnail would
|
|
not.
|
|
|
|
Every failure names the package it implicates. This fires on a Sunday,
|
|
unattended, about a change nobody made deliberately — "assertion failed" a week
|
|
later teaches nobody anything.
|
|
|
|
Usage: docker run --rm -i <image> shell -c 'python3 -' < scripts/smoke_image.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from PIL import Image
|
|
|
|
from backend.app.services.thumbnailer import Thumbnailer
|
|
except Exception as exc: # noqa: BLE001 — a smoke test reports, it never raises
|
|
print(f"smoke: FAILED — could not import the thumbnail path at all: {exc}")
|
|
print(" Implicates Pillow or its shared libraries (libjpeg62-turbo,")
|
|
print(" libpng16-16, libwebp7), or the python base image itself.")
|
|
raise SystemExit(1) from exc
|
|
|
|
|
|
# Binary → what stops working without it. Listed individually because
|
|
# `--no-install-recommends` means any one of them can vanish on its own when a
|
|
# dependency chain higher up changes.
|
|
REQUIRED_BINARIES = {
|
|
"ffmpeg": "video thumbnails and transcoding (Dockerfile: ffmpeg)",
|
|
"unar": "archive import — cbz/zip/rar members (Dockerfile: unar)",
|
|
"pg_dump": "database backup (Dockerfile: postgresql-client)",
|
|
"zstd": "backup compression, pg_dump | tar --zstd (Dockerfile: zstd)",
|
|
"megatools": "mega.nz public-link downloads, #830 (Dockerfile: megatools)",
|
|
}
|
|
|
|
|
|
def check_jpeg(thumbs: Thumbnailer, src: Path) -> None:
|
|
path = src / "flat.jpg"
|
|
Image.new("RGB", (900, 400), (30, 90, 160)).save(path, "JPEG")
|
|
result = thumbs.generate_image_thumbnail(path, "a" * 64)
|
|
assert result.mime == "image/jpeg", f"mime was {result.mime}"
|
|
assert result.path.stat().st_size > 0, "no bytes written"
|
|
# Re-open it. A file that writes but cannot be read back is the shape a
|
|
# half-broken codec produces, and size alone would not catch it.
|
|
with Image.open(result.path) as im:
|
|
im.load()
|
|
|
|
|
|
def check_png_alpha(thumbs: Thumbnailer, src: Path) -> None:
|
|
path = src / "alpha.png"
|
|
Image.new("RGBA", (400, 900), (200, 40, 40, 128)).save(path, "PNG")
|
|
result = thumbs.generate_image_thumbnail(path, "b" * 64)
|
|
assert result.mime == "image/png", f"mime was {result.mime}"
|
|
with Image.open(result.path) as im:
|
|
im.load()
|
|
assert im.mode in ("RGBA", "LA", "P"), f"alpha lost, mode={im.mode}"
|
|
|
|
|
|
def check_webp(thumbs: Thumbnailer, src: Path) -> None:
|
|
path = src / "sample.webp"
|
|
Image.new("RGB", (500, 500), (10, 140, 70)).save(path, "WEBP")
|
|
result = thumbs.generate_image_thumbnail(path, "c" * 64)
|
|
assert result.path.stat().st_size > 0, "no bytes written"
|
|
|
|
|
|
def check_video(thumbs: Thumbnailer, src: Path) -> None:
|
|
# Synthesised rather than committed as a fixture: a checked-in video is a
|
|
# binary blob nobody can review, and lavfi ships with every ffmpeg build.
|
|
#
|
|
# 3 seconds, not 2. The seek lands at max(1.0, duration * 0.05) = 1.0s, and
|
|
# a clip barely longer than its own seek is how #1231 produced zero frames.
|
|
# This check exists to exercise ffmpeg, not to re-litigate that edge.
|
|
clip = src / "clip.mp4"
|
|
subprocess.run(
|
|
["ffmpeg", "-nostdin", "-f", "lavfi", "-i", "testsrc=size=640x360:rate=10",
|
|
"-t", "3", "-pix_fmt", "yuv420p", "-y", str(clip)],
|
|
check=True, capture_output=True, timeout=120,
|
|
)
|
|
result = thumbs.generate_video_thumbnail(clip, "d" * 64, duration_seconds=3.0)
|
|
assert result.path.stat().st_size > 0, "no bytes written"
|
|
with Image.open(result.path) as im:
|
|
im.load()
|
|
|
|
|
|
CHECKS = (
|
|
("JPEG thumbnail", "libjpeg62-turbo / Pillow", check_jpeg),
|
|
("PNG thumbnail (alpha)", "libpng16-16 / Pillow", check_png_alpha),
|
|
("WebP decode", "libwebp7 / Pillow", check_webp),
|
|
("video thumbnail", "ffmpeg", check_video),
|
|
)
|
|
|
|
|
|
def main() -> int:
|
|
failures: list[str] = []
|
|
|
|
print("smoke: binaries the apt layer provides")
|
|
for binary, purpose in REQUIRED_BINARIES.items():
|
|
if shutil.which(binary) is None:
|
|
print(f" FAIL {binary}: not on PATH")
|
|
failures.append(f"{binary} — {purpose}")
|
|
else:
|
|
print(f" ok {binary}")
|
|
|
|
print("smoke: the application's own thumbnail path, against this image's libraries")
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
src = root / "src"
|
|
src.mkdir()
|
|
thumbs = Thumbnailer(root)
|
|
for name, implicates, fn in CHECKS:
|
|
try:
|
|
fn(thumbs, src)
|
|
print(f" ok {name}")
|
|
except Exception as exc: # noqa: BLE001 — report every check, then fail once
|
|
print(f" FAIL {name}: {exc}")
|
|
failures.append(f"{name} — {implicates}")
|
|
|
|
if failures:
|
|
print(f"\nsmoke: FAILED — {len(failures)} check(s)")
|
|
for failure in failures:
|
|
print(f" - {failure}")
|
|
print("\nThis image was built against freshly resolved base layers. The")
|
|
print("named packages are where to look: compare this build's apt versions")
|
|
print("against the previous :latest before assuming the app changed.")
|
|
return 1
|
|
|
|
print("\nsmoke: all checks passed")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|