Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@
# "stay current" layer, not the "react to a 0-day" layer.
#
# Dependabot only edits requirements.txt — it has no concept of the
# pip-compile lockfile the image installs from. Every pip PR therefore turns
# ci.yml's `lockfile-drift` job red until requirements.lock is recompiled:
# either run the `deps-lock` workflow on the PR branch, or take the lockfile
# that the failing job uploads as an artifact. That red gate is deliberate —
# when keeping the lockfile current was invisible manual work, it fell four
# packages behind.
# pip-compile lockfile the image installs from. A pip PR that raises a floor
# past the locked version therefore turns ci.yml's `lockfile-drift` job red
# until requirements.lock is recompiled — and `lint-and-test` too, whose
# install is constrained to the lockfile. Either run the `deps-lock` workflow
# on the PR branch, or take the lockfile that `lockfile-drift` uploads as an
# artifact. Those red gates are deliberate — when keeping the lockfile current
# was invisible manual work, it fell four packages behind.
#
# A base-image bump also moves the Python version the app ships on. The
# `python-version consistency` gate in ci.yml fails until the workflows and
Expand Down
33 changes: 32 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,39 @@ jobs:
sudo apt-get update
sudo apt-get install -y ffmpeg ghostscript libheif-dev libcairo2 libpangocairo-1.0-0 libgdk-pixbuf2.0-0

# Tests run against the versions the image ships. requirements-dev.txt
# pulls in requirements.txt, whose `>=` ranges resolve to the newest
# releases: SQLAlchemy 2.1.0 (2026-09-24) reached CI that way and failed a
# test on every branch, while the image stayed on 2.0.52. The lockfile —
# what the Dockerfile installs — therefore constrains every runtime
# package. Dev-only tools (pytest, ruff, uv, aiosqlite, …) are not in it
# and resolve as before; a dependency they share with the app (packaging,
# requests, …) stays at its locked version. The unpinned install runs
# weekly in deps-latest.yml as an early warning.
#
# The hashes are stripped because pip switches the whole install to
# --require-hashes as soon as one constraint carries a hash, and the dev
# tools are unhashed. The step fails if a lockfile entry did not become a
# constraint (or none did), instead of letting it install unpinned.
#
# A resolver conflict here usually means requirements.txt raised a floor
# past the locked version, as Dependabot pip PRs do: lockfile-drift is red
# as well, and recompiling the lockfile fixes both. If lockfile-drift is
# green, a dev tool wants a different version of a locked package. If it
# caps one below the pin, hold that dev-tool bump. If it needs a newer
# one, which a plain recompile does not move, move it explicitly — that
# changes what ships, so review it like a runtime bump (uv leaves the flag
# out of the lockfile header, so lockfile-drift stays green):
# uv pip compile --generate-hashes --python-version 3.14 --python-platform x86_64-unknown-linux-gnu --upgrade-package <name> --output-file requirements.lock requirements.txt
- name: Install Python dependencies
run: pip install -r requirements-dev.txt
run: |
constraints="$RUNNER_TEMP/constraints.txt"
grep -E '^[A-Za-z0-9._-]+==' requirements.lock | cut -d' ' -f1 > "$constraints"
if [ ! -s "$constraints" ] || grep -E '^[^[:space:]#]' requirements.lock | grep -qvE '^[A-Za-z0-9._-]+=='; then
echo "::error::Not every requirements.lock entry could be turned into a constraint (see the comment above this step in ci.yml)."
exit 1
fi
pip install -r requirements-dev.txt -c "$constraints"

- name: Lint (ruff)
run: ruff check .
Expand Down
60 changes: 60 additions & 0 deletions .github/workflows/deps-latest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# deps-latest — early warning: run the test suite against the newest releases
# that requirements.txt allows.
#
# ci.yml's lint-and-test constrains every runtime package to requirements.lock,
# so a PR is tested against what the image ships. That keeps an upstream
# release from turning every branch red at once, as SQLAlchemy 2.1.0 did in
# September 2026 while production stayed on 2.0.52. But it also means PR CI no
# longer notices such a release at all, and that notice was worth having: it
# arrived before any lockfile bump would have pulled 2.1 in.
#
# This workflow keeps it. It installs requirements-dev.txt unpinned, the way
# lint-and-test used to, and runs the tests. It gates nothing — a red run means
# the next lockfile bump needs work first, and the failing test is where to
# start. GitHub reports a failed scheduled run through its usual
# failed-workflow notification. To run it by hand, use the Actions tab or:
#
# gh workflow run deps-latest.yml

name: deps-latest

on:
schedule:
# Mondays — the day Dependabot opens its weekly pip PRs (dependabot.yml),
# so the result is at hand when reviewing them.
- cron: "23 4 * * 1"
workflow_dispatch:

permissions:
contents: read

jobs:
test-latest:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
# The shipped version, like every workflow (scripts/check_python_version.py).
python-version: "3.14"

# Same packages, and the same Google-Chrome apt-source workaround, as
# ci.yml's lint-and-test — see the comment there.
- name: Install system dependencies
run: |
sudo find /etc/apt/sources.list.d -iname '*google*' -delete
sudo apt-get update
sudo apt-get install -y ffmpeg ghostscript libheif-dev libcairo2 libpangocairo-1.0-0 libgdk-pixbuf2.0-0

# Deliberately without the lockfile constraints: the newest releases the
# ranges allow are what this job is for.
- name: Install Python dependencies (unpinned)
run: pip install -r requirements-dev.txt

- name: Run tests
run: pytest tests/ -v --tb=short
234 changes: 117 additions & 117 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -1,117 +1,117 @@
name: Docker
on:
push:
branches: ["main"]
tags: ["v*"]
# Manual rebuild. Without this there is no way to produce an image, or to
# reach `notify-ops` and the deploy that follows it, other than pushing to
# main — and main is protected, so that means opening a PR.
#
# That gap cost three weeks in September 2026: the credential `notify-ops`
# uses had expired, so it failed with HTTP 401 on every run and nothing
# reached production. Once it was replaced there was no way to prove the fix,
# or to redeploy, without inventing a code change. A dispatch here rebuilds
# from the current main and runs the whole chain, which is also what you want
# during an incident.
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
strategy:
# Build slim + office in parallel; fail-fast off because the two
# variants are independent — a Pango regression in slim shouldn't
# mask an upstream LibreOffice break in office (or vice versa).
fail-fast: false
matrix:
include:
- target: base
suffix: ""
description: "Slim image (mammoth+WeasyPrint docx→pdf path)"
- target: office
suffix: "-office"
description: "Office image (adds LibreOffice for high-fidelity docx→pdf)"
permissions:
contents: read
packages: write
# NEU-B.4: cosign keyless signing uses the GitHub Actions OIDC
# token to prove identity to Sigstore's Fulcio CA — no long-lived
# signing key to manage or rotate. Verifiable later with
# cosign verify ghcr.io/<owner>/<repo>@sha256:<digest> \
# --certificate-identity-regexp "^https://github\\.com/<owner>/<repo>/" \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The default ``docker`` driver on GHA runners doesn't support
# ``cache-to: type=gha`` (GitHub Actions cache backend). Switching
# to buildx's ``docker-container`` driver enables the cache export
# so the matrix matrix-base / matrix-office leg can reuse the
# base-stage layers it already pulled.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# The office variant gets the same tag set as slim but with a
# ``-office`` suffix so a consumer can pick either by tag —
# filemorph:1.1.0 vs filemorph:1.1.0-office. The slim image
# keeps ``:latest`` (default for naive pulls); the office image
# gets ``:office`` for the matching shorthand.
tags: |
type=semver,pattern={{version}},suffix=${{ matrix.suffix }}
type=semver,pattern={{major}}.{{minor}},suffix=${{ matrix.suffix }}
type=raw,value=${{ matrix.target == 'base' && 'latest' || 'office' }}
type=sha,format=short,prefix=sha-,suffix=${{ matrix.suffix }}
- name: Build and push Docker image (${{ matrix.target }})
id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
target: ${{ matrix.target }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# GHA-cache lets the office stage reuse every layer the base
# stage already produced — without it the office build redoes
# ffmpeg, ghostscript, pip install. Scope is per-target so the
# two matrix legs don't trample each other's cache.
cache-from: type=gha,scope=${{ matrix.target }}
cache-to: type=gha,scope=${{ matrix.target }},mode=max
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: "v2.4.1"
- name: Sign published image (keyless / Sigstore)
env:
COSIGN_EXPERIMENTAL: "true"
DIGEST: ${{ steps.build.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
# Sign every tag that got pushed at the SAME digest so a downstream
# `cosign verify ghcr.io/.../filemorph:<tag>` works regardless of
# which alias the consumer pulls.
run: |
set -euo pipefail
for tag in $TAGS; do
cosign sign --yes "${tag}@${DIGEST}"
done
name: Docker

on:
push:
branches: ["main"]
tags: ["v*"]
# Manual rebuild. Without this there is no way to produce an image, or to
# reach `notify-ops` and the deploy that follows it, other than pushing to
# main — and main is protected, so that means opening a PR.
#
# That gap cost three weeks in September 2026: the credential `notify-ops`
# uses had expired, so it failed with HTTP 401 on every run and nothing
# reached production. Once it was replaced there was no way to prove the fix,
# or to redeploy, without inventing a code change. A dispatch here rebuilds
# from the current main and runs the whole chain, which is also what you want
# during an incident.
workflow_dispatch:

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
build-and-push:
runs-on: ubuntu-latest
strategy:
# Build slim + office in parallel; fail-fast off because the two
# variants are independent — a Pango regression in slim shouldn't
# mask an upstream LibreOffice break in office (or vice versa).
fail-fast: false
matrix:
include:
- target: base
suffix: ""
description: "Slim image (mammoth+WeasyPrint docx→pdf path)"
- target: office
suffix: "-office"
description: "Office image (adds LibreOffice for high-fidelity docx→pdf)"
permissions:
contents: read
packages: write
# NEU-B.4: cosign keyless signing uses the GitHub Actions OIDC
# token to prove identity to Sigstore's Fulcio CA — no long-lived
# signing key to manage or rotate. Verifiable later with
# cosign verify ghcr.io/<owner>/<repo>@sha256:<digest> \
# --certificate-identity-regexp "^https://github\\.com/<owner>/<repo>/" \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com
id-token: write

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

# The default ``docker`` driver on GHA runners doesn't support
# ``cache-to: type=gha`` (GitHub Actions cache backend). Switching
# to buildx's ``docker-container`` driver enables the cache export
# so the matrix matrix-base / matrix-office leg can reuse the
# base-stage layers it already pulled.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0

- name: Log in to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# The office variant gets the same tag set as slim but with a
# ``-office`` suffix so a consumer can pick either by tag —
# filemorph:1.1.0 vs filemorph:1.1.0-office. The slim image
# keeps ``:latest`` (default for naive pulls); the office image
# gets ``:office`` for the matching shorthand.
tags: |
type=semver,pattern={{version}},suffix=${{ matrix.suffix }}
type=semver,pattern={{major}}.{{minor}},suffix=${{ matrix.suffix }}
type=raw,value=${{ matrix.target == 'base' && 'latest' || 'office' }}
type=sha,format=short,prefix=sha-,suffix=${{ matrix.suffix }}

- name: Build and push Docker image (${{ matrix.target }})
id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
target: ${{ matrix.target }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# GHA-cache lets the office stage reuse every layer the base
# stage already produced — without it the office build redoes
# ffmpeg, ghostscript, pip install. Scope is per-target so the
# two matrix legs don't trample each other's cache.
cache-from: type=gha,scope=${{ matrix.target }}
cache-to: type=gha,scope=${{ matrix.target }},mode=max

- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: "v2.4.1"

- name: Sign published image (keyless / Sigstore)
env:
COSIGN_EXPERIMENTAL: "true"
DIGEST: ${{ steps.build.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
# Sign every tag that got pushed at the SAME digest so a downstream
# `cosign verify ghcr.io/.../filemorph:<tag>` works regardless of
# which alias the consumer pulls.
run: |
set -euo pipefail
for tag in $TAGS; do
cosign sign --yes "${tag}@${DIGEST}"
done
12 changes: 8 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,11 @@ jobs:
# release-triggered SBOM workflow would silently never run — which is
# exactly what happened for v1.1.0. Bundling it here guarantees every
# release tag ships its CycloneDX SBOM (docs/patch-policy.md promise +
# EVB-IT-March-2026 SBOM mandate). `environment` captures the resolved
# transitive dependency graph, matching the SBOM workflow's main-push path.
# EVB-IT-March-2026 SBOM mandate). The method is the SBOM workflow's
# main-push one — the image's install (Dockerfile: `pip install
# --require-hashes -r requirements.lock`) into a venv of its own, the
# generator outside it; sbom.yml's header explains why. Keep the two in
# step: this path only runs on a release, sbom.yml on every push to main.
- name: Set up Python (for SBOM)
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
Expand All @@ -90,9 +93,10 @@ jobs:
- name: Generate CycloneDX SBOM
run: |
TAG="${GITHUB_REF#refs/tags/}"
pip install -r requirements.txt
python -m venv "$RUNNER_TEMP/image-env"
"$RUNNER_TEMP/image-env/bin/pip" install --require-hashes -r requirements.lock
pip install "cyclonedx-bom>=5,<6"
cyclonedx-py environment \
cyclonedx-py environment "$RUNNER_TEMP/image-env/bin/python" \
--output-file "filemorph-${TAG}.cdx.json" \
--output-format JSON

Expand Down
Loading
Loading