From 362346ff94868c2527b87dfbca0cb8562a5d7c61 Mon Sep 17 00:00:00 2001 From: MrChengLen Date: Sat, 26 Sep 2026 15:31:09 +0200 Subject: [PATCH] =?UTF-8?q?fix(ci):=20renormalize=20docker.yml=20to=20LF?= =?UTF-8?q?=20=E2=80=94=20and=20fail=20CI=20on=20CRLF=20blobs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker.yml has been stored with CRLF line endings since f72dedc, although .gitattributes pins *.yml to LF. That commit went through the GraphQL createCommitOnBranch API, which stores exactly the bytes it is sent, and the bytes it was sent had CRLF line endings, so an 11-line change already showed up as 223 changed lines. GitHub runs the workflow either way, so nothing failed. The next cost would come with the next edit: its `git add` renormalises all 117 lines and buries the real change in another whole-file diff. This commit is that renormalisation and nothing else. `git diff -w` against main is empty, and the new blob is the old one with every CRLF replaced by LF. tests/test_line_endings.py reads `git ls-files --eol` and fails on any tracked file whose index blob is CRLF or mixed, unless .gitattributes marks it -text. It covers every tracked file, not only *.yml/*.py/*.md: `* text=auto` normalises all text, and the files most at risk are the text=auto ones that a Windows checkout writes as CRLF (requirements.lock was one: 7caa75e in PR #81, repaired in PR #85). It asserts "not CRLF" rather than "is LF" because i/none is legitimate for the .gitkeep files and the one-line Tailwind bundle. It reads the index, not the working tree, so a Windows checkout with autocrlf stays green. Verified red against an index built from main (it names exactly .github/workflows/docker.yml) and green with this change. Dependabot PR #131 bumps two actions in docker.yml on top of the CRLF version, so it will conflict once this lands. Dependabot rebases conflicted PRs by default, which regenerates the bump on the LF file. Full suite 1250 passed, 64 skipped locally on Windows (the WeasyPrint and pikepdf tests run in CI); ruff check + ruff format clean; gitleaks and the pre-commit scope guard clean; security-auditor and code-reviewer approve. i18n drift and pip-audit not applicable: no templates, catalogs or requirements touched. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docker.yml | 234 +++++++++++++++++------------------ CHANGELOG.md | 16 +++ tests/test_line_endings.py | 52 ++++++++ 3 files changed, 185 insertions(+), 117 deletions(-) create mode 100644 tests/test_line_endings.py diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index b1facab..01f23fd 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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//@sha256: \ - # --certificate-identity-regexp "^https://github\\.com///" \ - # --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:` 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//@sha256: \ + # --certificate-identity-regexp "^https://github\\.com///" \ + # --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:` works regardless of + # which alias the consumer pulls. + run: | + set -euo pipefail + for tag in $TAGS; do + cosign sign --yes "${tag}@${DIGEST}" + done diff --git a/CHANGELOG.md b/CHANGELOG.md index aedf8e1..b0151ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed — `docker.yml` stored with CRLF line endings; CI now rejects CRLF files + +`.github/workflows/docker.yml` had been stored with Windows line endings (CRLF) +since the manual-rebuild change (`f72dedc`), although `.gitattributes` pins YAML +to LF. GitHub runs the workflow either way, so nothing broke, but the next +ordinary edit would have converted all 117 lines and shown up as a whole-file +diff that hides the lines actually changed. The file is back to LF; its content +is unchanged (`git diff -w` is empty). + +`.gitattributes` only normalises line endings when git itself stages a file. A +commit made through the GitHub API stores exactly the bytes it is sent, and a +file read from a Windows checkout can carry CRLF, which is how +`requirements.lock` once turned into a 3725-line diff. `tests/test_line_endings.py` +now fails CI when any tracked text file is stored with CRLF, so the next one is +caught in its own pull request rather than by whoever edits the file after it. + ### Fixed — long upload names lost their file extension on download `safe_download_name()` cut the finished download name to 200 characters, so an diff --git a/tests/test_line_endings.py b/tests/test_line_endings.py new file mode 100644 index 0000000..e6af5c0 --- /dev/null +++ b/tests/test_line_endings.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Tracked text files are stored with LF line endings. + +``.gitattributes`` normalises text files to LF, but only when git itself stages +them. A commit made through the GitHub API (``createCommitOnBranch``) stores +exactly the bytes it is sent, CRLF included: ``requirements.lock`` landed that +way in 7caa75e (PR #81, repaired in PR #85), ``.github/workflows/docker.yml`` in +f72dedc. Nothing breaks, but that commit is already a whole-file diff, and for a +file pinned to ``eol=lf`` the next ordinary edit renormalises it into another. + +This reads the index (the ``i/`` column of ``git ls-files --eol``), not the +working tree: a Windows checkout may hold CRLF on disk, and what matters is the +blob that gets committed. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parent.parent + + +def test_no_tracked_text_file_is_stored_with_crlf() -> None: + if not (_REPO_ROOT / ".git").exists(): + pytest.skip("not a git checkout (e.g. an unpacked release tarball)") + listing = subprocess.run( + ["git", "ls-files", "--eol", "-z"], + cwd=_REPO_ROOT, + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ).stdout + offenders = [] + for entry in filter(None, listing.split("\0")): + # "i/ w/ attr/\t"; -z leaves the path unquoted. + info, path = entry.split("\t", 1) + index_eol, _worktree_eol, attr = info.split(maxsplit=2) + # `-text` (or `binary`) in .gitattributes opts a file out of + # normalisation, e.g. an .eml fixture that has to keep CRLF. + if index_eol in ("i/crlf", "i/mixed") and "-text" not in attr: + offenders.append(f"{index_eol:8} {path}") + assert not offenders, ( + "stored with CRLF line endings, although .gitattributes normalises them to LF:\n " + + "\n ".join(offenders) + + "\nFix: `git add --renormalize `. For a commit through the API, convert " + "\\r\\n to \\n before base64-encoding. A file that has to keep CRLF needs " + "`-text` in .gitattributes." + )