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
6 changes: 6 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

### Fixed — patch-policy's `cosign verify` names an image tag that exists

`docs/patch-policy.md` told readers to verify the release image
Expand Down
48 changes: 47 additions & 1 deletion tests/test_supply_chain_hygiene.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
Loading