Skip to content

Commit db3ee8d

Browse files
committed
Make latest a multi-arch tag in the normal deploy flow
Determine the canonical latest image from the current build set and publish `latest` during the normal deploy flow alongside the corresponding versioned tag. This replaces the separate `latest` build path, so `latest` is published for all supported architectures without relying on a separate Docker Hub build. Remove the root Dockerfile because it only existed for the separate Docker Hub Automated Build path. Any Docker Hub automated build still configured to use that file must be disabled in Docker Hub. Fixes nikolaik#263.
1 parent e247afd commit db3ee8d

7 files changed

Lines changed: 165 additions & 37 deletions

File tree

.github/workflows/build.yaml

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ jobs:
2929
outputs:
3030
version_matrix: ${{ steps.set-matrix.outputs.matrix }}
3131
arch_matrix: ${{ steps.set-matrix.outputs.arch_matrix }}
32+
latest_key: ${{ steps.set-matrix.outputs.latest_key }}
3233
steps:
3334
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
3435
with:
@@ -104,6 +105,9 @@ jobs:
104105
refs+=("${IMAGE_NAME}:${{ matrix.key }}-arm64")
105106
fi
106107
docker manifest create "${IMAGE_NAME}:${{ matrix.key }}" "${refs[@]}"
108+
if [[ "${{ needs.generate-matrix.outputs.latest_key }}" == "${{ matrix.key }}" ]]; then
109+
docker manifest create "${IMAGE_NAME}:latest" "${refs[@]}"
110+
fi
107111
108112
- name: Login to Docker Hub
109113
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4
@@ -112,15 +116,22 @@ jobs:
112116
password: ${{ secrets.DOCKERHUB_TOKEN }}
113117

114118
- name: Push multi-arch manifest
115-
run: docker manifest push "${IMAGE_NAME}:${{ matrix.key }}"
119+
id: push-manifest
120+
run: |
121+
digest="$(docker manifest push "${IMAGE_NAME}:${{ matrix.key }}" | tail -n1)"
122+
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
123+
124+
- name: Push latest manifest
125+
if: needs.generate-matrix.outputs.latest_key == matrix.key
126+
run: docker manifest push "${IMAGE_NAME}:latest"
116127

117128
- name: Set up Docker Buildx
118129
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
119130

120131
- name: Add digest to build context
121132
run: |
122133
mkdir builds/
123-
digest="$(docker buildx imagetools inspect "${IMAGE_NAME}:${{ matrix.key }}" | awk '/^Digest:/ {print $2}')"
134+
digest="${{ steps.push-manifest.outputs.digest }}"
124135
echo '${{ toJSON(matrix) }}' | jq --arg digest "$digest" '. +={"digest": $digest}' >> "builds/${{ matrix.key }}.json"
125136
126137
- name: Upload build context

Dockerfile

Lines changed: 0 additions & 32 deletions
This file was deleted.

README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,6 @@ Versions are kept up to date using official sources. For Python we scrape the _S
139139
```bash
140140
# Pull from Docker Hub
141141
docker pull nikolaik/python-nodejs:latest
142-
# Build from GitHub
143-
docker build -t nikolaik/python-nodejs github.com/nikolaik/docker-python-nodejs
144142
# Run image
145143
docker run -it nikolaik/python-nodejs bash
146144
```

src/docker_python_nodejs/build_matrix.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
if TYPE_CHECKING:
1010
from .versions import BuildVersion
1111

12+
from .versions import latest_tag_key
13+
1214
CI_EVENT_SCHEDULED = "scheduled"
1315

1416
logger = logging.getLogger("dpn")
@@ -60,7 +62,9 @@ def build_matrix(new_or_updated: list[BuildVersion], ci_event: str) -> None:
6062

6163
matrix = _build_matrix_json(new_or_updated)
6264
arch_matrix = _build_arch_matrix_json(new_or_updated)
65+
latest_key = latest_tag_key(new_or_updated) if new_or_updated else ""
6366
_github_action_set_output("matrix", matrix)
6467
_github_action_set_output("arch_matrix", arch_matrix)
68+
_github_action_set_output("latest_key", latest_key)
6569
logger.info("\n# New or updated versions:")
6670
logger.info("Nothing" if not new_or_updated else "\n".join(version.key for version in new_or_updated))

src/docker_python_nodejs/nodejs_versions.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import datetime
2+
import re
23
from typing import TypedDict
34

45
import requests
@@ -29,6 +30,17 @@ def fetch_node_unofficial_releases() -> list[NodeRelease]:
2930
return data
3031

3132

33+
def fetch_latest_nodejs_version() -> str:
34+
url = "https://nodejs.org/dist/latest/SHASUMS256.txt"
35+
res = requests.get(url, timeout=10.0)
36+
res.raise_for_status()
37+
match = re.search(r"node-(v\d+\.\d+\.\d+)-", res.text)
38+
if not match:
39+
msg = "Could not determine latest Node.js version from SHASUMS256.txt"
40+
raise ValueError(msg)
41+
return match.group(1)
42+
43+
3244
class ReleaseScheduleItem(TypedDict):
3345
start: str
3446
lts: str

src/docker_python_nodejs/versions.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from .docker_hub import DockerImageDict, DockerTagDict, fetch_tags
1616
from .nodejs_versions import (
17+
fetch_latest_nodejs_version,
1718
fetch_node_releases,
1819
fetch_node_unofficial_releases,
1920
fetch_nodejs_release_schedule,
@@ -105,6 +106,17 @@ def _latest_patch(tags: list[DockerTagDict], ver: str, distro: str) -> str | Non
105106
return sorted(tags, key=lambda x: Version.parse(x["name"]), reverse=True)[0]["name"] if tags else None
106107

107108

109+
def _latest_python_minor(distro: str) -> str:
110+
python_patch_re = re.compile(rf"^(\d+\.\d+\.\d+)-{distro}$")
111+
tags = [tag["name"] for tag in fetch_tags("python") if python_patch_re.match(tag["name"])]
112+
if not tags:
113+
msg = f"Could not determine latest Python version for distro '{distro}'"
114+
raise ValueError(msg)
115+
116+
latest_patch = sorted(tags, key=lambda x: Version.parse(x.removesuffix(f"-{distro}")), reverse=True)[0]
117+
return ".".join(latest_patch.removesuffix(f"-{distro}").split(".")[:2])
118+
119+
108120
def scrape_supported_python_versions() -> list[SupportedVersion]:
109121
"""Scrape supported python versions (risky)."""
110122
versions = []
@@ -256,6 +268,18 @@ def decide_version_combinations(
256268
return version_combinations(nodejs_versions, python_versions)
257269

258270

271+
def latest_tag_key(versions: list[BuildVersion]) -> str:
272+
python_minor = _latest_python_minor(DEFAULT_DISTRO)
273+
node_major = fetch_latest_nodejs_version().removeprefix("v").split(".")[0]
274+
key = f"python{python_minor}-nodejs{node_major}"
275+
276+
if key not in {version.key for version in versions}:
277+
msg = f"Computed latest tag '{key}' was not part of the current build set"
278+
raise ValueError(msg)
279+
280+
return key
281+
282+
259283
def persist_versions(versions: list[BuildVersion], dry_run: bool = False) -> None:
260284
if dry_run:
261285
logger.debug(versions)

tests/test_all.py

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import pytest
99
import responses
1010

11-
from docker_python_nodejs.build_matrix import _build_arch_matrix_json
11+
from docker_python_nodejs.build_matrix import _build_arch_matrix_json, _build_matrix_json
1212
from docker_python_nodejs.dockerfiles import render_dockerfile_with_context
1313
from docker_python_nodejs.readme import update_dynamic_readme
1414
from docker_python_nodejs.settings import BASE_PATH, DOCKERFILES_PATH
@@ -19,6 +19,7 @@
1919
decide_version_combinations,
2020
fetch_supported_nodejs_versions,
2121
find_new_or_updated,
22+
latest_tag_key,
2223
load_build_contexts,
2324
scrape_supported_python_versions,
2425
)
@@ -327,3 +328,113 @@ def test_build_arch_matrix_json(build_version: BuildVersion) -> None:
327328
},
328329
],
329330
}
331+
332+
333+
def test_build_matrix_json() -> None:
334+
versions = [
335+
BuildVersion(
336+
key="python3.14-nodejs25",
337+
python="3.14",
338+
python_canonical="3.14.3",
339+
python_image="3.14.3-trixie",
340+
nodejs="25",
341+
nodejs_canonical="25.8.1",
342+
distro="trixie",
343+
platforms=["linux/amd64", "linux/arm64"],
344+
),
345+
BuildVersion(
346+
key="python3.14-nodejs24-bookworm",
347+
python="3.14",
348+
python_canonical="3.14.3",
349+
python_image="3.14.3-bookworm",
350+
nodejs="24",
351+
nodejs_canonical="24.14.0",
352+
distro="bookworm",
353+
platforms=["linux/amd64", "linux/arm64"],
354+
),
355+
]
356+
357+
matrix = json.loads(_build_matrix_json(versions))
358+
359+
assert matrix == {
360+
"include": [
361+
{
362+
"key": "python3.14-nodejs25",
363+
"python": "3.14",
364+
"python_canonical": "3.14.3",
365+
"python_image": "3.14.3-trixie",
366+
"nodejs": "25",
367+
"nodejs_canonical": "25.8.1",
368+
"distro": "trixie",
369+
"platforms": ["linux/amd64", "linux/arm64"],
370+
"digest": "",
371+
},
372+
{
373+
"key": "python3.14-nodejs24-bookworm",
374+
"python": "3.14",
375+
"python_canonical": "3.14.3",
376+
"python_image": "3.14.3-bookworm",
377+
"nodejs": "24",
378+
"nodejs_canonical": "24.14.0",
379+
"distro": "bookworm",
380+
"platforms": ["linux/amd64", "linux/arm64"],
381+
"digest": "",
382+
},
383+
],
384+
}
385+
386+
387+
def test_latest_tag_key_matches_latest_sources() -> None:
388+
versions = [
389+
BuildVersion(
390+
key="python3.14-nodejs25",
391+
python="3.14",
392+
python_canonical="3.14.3",
393+
python_image="3.14.3-trixie",
394+
nodejs="25",
395+
nodejs_canonical="25.8.1",
396+
distro="trixie",
397+
platforms=["linux/amd64", "linux/arm64"],
398+
),
399+
BuildVersion(
400+
key="python3.14-nodejs24-bookworm",
401+
python="3.14",
402+
python_canonical="3.14.3",
403+
python_image="3.14.3-bookworm",
404+
nodejs="24",
405+
nodejs_canonical="24.14.0",
406+
distro="bookworm",
407+
platforms=["linux/amd64", "linux/arm64"],
408+
),
409+
]
410+
411+
with (
412+
mock.patch("docker_python_nodejs.versions._latest_python_minor", return_value="3.14"),
413+
mock.patch("docker_python_nodejs.versions.fetch_latest_nodejs_version", return_value="v25.8.1"),
414+
):
415+
assert latest_tag_key(versions) == "python3.14-nodejs25"
416+
417+
418+
def test_latest_tag_key_fails_if_canonical_build_is_missing() -> None:
419+
versions = [
420+
BuildVersion(
421+
key="python3.14-nodejs24",
422+
python="3.14",
423+
python_canonical="3.14.3",
424+
python_image="3.14.3-trixie",
425+
nodejs="24",
426+
nodejs_canonical="24.14.0",
427+
distro="trixie",
428+
platforms=["linux/amd64", "linux/arm64"],
429+
),
430+
]
431+
432+
with (
433+
mock.patch("docker_python_nodejs.versions._latest_python_minor", return_value="3.14"),
434+
mock.patch("docker_python_nodejs.versions.fetch_latest_nodejs_version", return_value="v25.8.1"),
435+
pytest.raises(
436+
ValueError,
437+
match=r"Computed latest tag 'python3\.14-nodejs25' was not part of the current build set",
438+
),
439+
):
440+
latest_tag_key(versions)

0 commit comments

Comments
 (0)