diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 5005a79..c947166 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -84,6 +84,12 @@ jobs: 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 }} + # ``:latest`` comes from the raw entry above, and only from it. By + # default (``latest=auto``) the action also adds a ``latest`` tag + # to both variants on a release tag, without the ``-office`` + # suffix, so whichever image finished last would keep ``:latest`` — + # usually the office image, the slower build. + flavor: latest=false - name: Build and push Docker image (${{ matrix.target }}) id: build diff --git a/CHANGELOG.md b/CHANGELOG.md index 2127d5f..0769474 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,37 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed — after a release, `:latest` is the slim image, not the office image + +`docker.yml` tags the slim image `:latest` and the office image `:office`, but +`docker/metadata-action` added a `latest` tag of its own on every release: with +its default flavor, `latest=auto`, a release tag (`vX.Y.Z` without a +pre-release part) that matches a `type=semver` entry gets `latest` as well, and +the office entries' `suffix=-office` does not apply to it. Both images were +pushed as `:latest`, and the one that finished last kept the tag — usually the +office image, the slower build. Whoever pulled `:latest` then got LibreOffice +without choosing the office image, and with the default +`FILEMORPH_OFFICE_ENGINE=auto` complex DOCX files were converted through it. +In the v1.1.0 run the office image's build and push finished 12 seconds after +the slim image's, so on 2026-06-01 `:latest` was most likely the office image +for about an hour, until the next build of `main`; if you pulled it then and +not since, pull it again. Builds of `main` were not affected. Found by the security review of +PR #180. + +- **Fix.** The metadata step sets `flavor: latest=false`, so `:latest` comes + only from the `type=raw` entry that names it for the slim image. The other + tags stay as they were: `:office`, and `X.Y.Z`, `X.Y` and `sha-…`, each with + `-office` on the office image. The docs already call `:latest` the slim + image; that now holds right after a release too. `docker.yml` pushes images + and cannot be tried before merge, so the pinned action (v6.2.0) was run + locally on the workflow's inputs: it now tags only the slim image `latest`, + on a release tag, a pre-release tag and `main`. The next release is the + first real test. +- **Guard.** `tests/test_supply_chain_hygiene.py` fails if the metadata step's + flavor is anything but `latest=false`, if another of its tag entries names + `latest`, or if the matrix loses the unsuffixed `base` leg the `type=raw` + entry is meant for. + ### Security — urllib3 2.8.0: three advisories published on 2026-09-30 On 2026-10-01 `pip-audit` flagged urllib3 2.7.0 in `requirements.lock`, and diff --git a/tests/test_supply_chain_hygiene.py b/tests/test_supply_chain_hygiene.py index 5bb5e50..bfc83b2 100644 --- a/tests/test_supply_chain_hygiene.py +++ b/tests/test_supply_chain_hygiene.py @@ -35,7 +35,10 @@ digest its build step reports: the SBOM comes from sbom.yml's steps in a job that can only read, and the job that signs the attestation checks nothing out, installs nothing and runs only GitHub's own actions; - docs/release-signing.md verifies it the way it is made. + docs/release-signing.md verifies it the way it is made; + * only the slim image is tagged ``:latest``: docker.yml switches off + metadata-action's automatic ``latest`` tag, which a release would + otherwise add to the office image too. This is a tripwire, not a substitute for the server-side Scorecard run / review: the per-job permissions check here is a heuristic (it asserts a @@ -1095,6 +1098,49 @@ def test_docs_verify_the_sbom_attestation_as_docker_yml_makes_it() -> None: ) +def test_only_the_slim_image_is_tagged_latest() -> None: + """docker.yml tags the slim image ``:latest``, and only the slim image. + + metadata-action adds a ``latest`` tag of its own when a ``type=semver`` + entry matches a release tag (``flavor: latest=auto``, its default), and + a tag entry's ``suffix=`` does not reach that tag: on a release both + variants pushed ``:latest``, and whichever finished last kept it — + usually the office image, the slower build. With ``latest=false`` the + ``type=raw`` entry that names ``latest`` for the unsuffixed base leg is + the only source of ``:latest``, so that entry and that leg have to stay. + The flavor is pinned whole: the action skips only lines that start with + ``#`` and unquotes CSV fields, so a looser match could pass a flavor it + reads differently. + """ + job = _workflow(_WORKFLOW_DIR / "docker.yml")["jobs"]["build-and-push"] + legs = {leg["target"]: leg.get("suffix") for leg in job["strategy"]["matrix"]["include"]} + assert legs.get("base") == "", ( + f"docker.yml: the `latest` entry is meant for the `base` leg, the slim image without " + f"a suffix; the matrix has {legs}" + ) + steps = [s for s in _steps(job) if str(s.get("uses", "")).startswith("docker/metadata-action@")] + assert steps, "docker.yml no longer uses docker/metadata-action — update this guard" + raw_latest = "type=raw,value=${{ matrix.target == 'base' && 'latest' || 'office' }}" + for step in steps: + inputs = step.get("with") or {} + assert str(inputs.get("flavor", "")).strip() == "latest=false", ( + f"docker.yml step {step.get('name')!r}: `flavor:` must be exactly `latest=false` " + f"(this guard pins it whole) — without it, a release tags the office image " + f"`:latest` as well" + ) + latest = [ + line.strip() + for line in str(inputs.get("tags", "")).splitlines() + # As in the action, only a line starting with "#" is a comment; an + # indented "# type=raw,value=latest" is a tag for both images. + if "latest" in line and not line.startswith("#") + ] + assert latest == [raw_latest], ( + f"docker.yml step {step.get('name')!r}: `:latest` comes from one `type=raw` entry, " + f"for the base leg only; found {latest}" + ) + + def test_verapdf_image_is_digest_pinned() -> None: """The veraPDF gate runs a validator image pinned by digest.