From 7ca49447eda359448ec909ca5b04ed820d5faa88 Mon Sep 17 00:00:00 2001 From: Stefan Kuhn Date: Mon, 16 Mar 2026 15:44:20 +0000 Subject: [PATCH 1/3] Make workflow image name overrideable from Actions vars Keep nikolaik/python-nodejs as the default image name in the workflow, but resolve it through a GitHub Actions variable so forks can publish to a different image without changing the repository. Forks can set IMAGE_NAME in Actions variables or in workflow run configuration. That keeps the default behavior unchanged here while avoiding fork-specific edits in PRs. --- .github/workflows/build.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f1a548e..817c001 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -6,6 +6,9 @@ on: schedule: - cron: "0 00,12 * * *" # Twice a day +env: + IMAGE_NAME: ${{ vars.IMAGE_NAME || 'nikolaik/python-nodejs' }} + jobs: generate-matrix: name: Generate build matrix @@ -59,12 +62,12 @@ jobs: context: . file: dockerfiles/${{ matrix.key }}.Dockerfile load: true - tags: nikolaik/python-nodejs:${{ matrix.key }} + tags: ${{ env.IMAGE_NAME }}:${{ matrix.key }} # Test - name: Run smoke tests run: | - docker run --rm nikolaik/python-nodejs:${{ matrix.key }} sh -c "node --version && npm --version && yarn --version && python --version && pip --version && pipenv --version && poetry --version && uv --version" + docker run --rm ${{ env.IMAGE_NAME }}:${{ matrix.key }} sh -c "node --version && npm --version && yarn --version && python --version && pip --version && pipenv --version && poetry --version && uv --version" # Push image - name: Push image @@ -75,7 +78,7 @@ jobs: file: dockerfiles/${{ matrix.key }}.Dockerfile platforms: ${{ join(matrix.platforms) }} push: true - tags: nikolaik/python-nodejs:${{ matrix.key }} + tags: ${{ env.IMAGE_NAME }}:${{ matrix.key }} # Store build context - name: Add digest to build context From e59a27ece5c8fa56bc2039d6ca195662b7b1de57 Mon Sep 17 00:00:00 2001 From: Stefan Kuhn Date: Mon, 16 Mar 2026 17:27:07 +0000 Subject: [PATCH 2/3] Add manual deploy trigger with run-time overrides Allow manual workflow runs from GitHub Actions with a force option and an optional IMAGE_NAME override. This makes it possible to test publish flows without overwriting the real published images tags. --- .github/workflows/build.yaml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 817c001..2e5ad62 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -5,9 +5,21 @@ on: branches: [main] schedule: - cron: "0 00,12 * * *" # Twice a day + workflow_dispatch: + inputs: + force: + description: Force rebuild and republish all image tags + required: false + default: true + type: boolean + image_name: + description: Override image name for this manual run + required: false + default: "" + type: string env: - IMAGE_NAME: ${{ vars.IMAGE_NAME || 'nikolaik/python-nodejs' }} + IMAGE_NAME: ${{ inputs.image_name || vars.IMAGE_NAME || 'nikolaik/python-nodejs' }} jobs: generate-matrix: @@ -26,7 +38,7 @@ jobs: - name: Generate build matrix id: set-matrix run: | - FORCE=$(if git log --pretty=format:"%s" HEAD^..HEAD | grep -q '\[force\]'; then echo "--force"; else echo ""; fi) + FORCE=$(if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.force }}" == "true" ]]; then echo "--force"; elif git log --pretty=format:"%s" HEAD^..HEAD | grep -q '\[force\]'; then echo "--force"; else echo ""; fi) uv run dpn $FORCE build-matrix --event ${{ github.event_name }} From c0ee3921fed2b0e0726e8d0f61881e3380f6e36e Mon Sep 17 00:00:00 2001 From: Stefan Kuhn Date: Mon, 16 Mar 2026 16:51:53 +0000 Subject: [PATCH 3/3] Make latest a post-build multi-arch alias Determine the canonical latest image from the current build set and publish `latest` after the multi-arch images have been built. This replaces the separate `latest` build path with a post-build tag publish, so `latest` is published for all supported architectures. Use `regctl image copy` to retag the canonical multi-arch image without rebuilding it. This also avoids additional authenticated registry reads during latest-tag publication, which helps stay clear of Docker Hub pull-rate limits. 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 #263. --- .github/workflows/build.yaml | 29 ++++++++++- Dockerfile | 32 ------------ README.md | 2 - src/docker_python_nodejs/cli.py | 23 ++++++++- src/docker_python_nodejs/nodejs_versions.py | 12 +++++ src/docker_python_nodejs/versions.py | 24 +++++++++ tests/test_all.py | 57 +++++++++++++++++++++ 7 files changed, 142 insertions(+), 37 deletions(-) delete mode 100644 Dockerfile diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 2e5ad62..425528c 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -107,10 +107,37 @@ jobs: if-no-files-found: error retention-days: 1 + tag-latest: + name: Point latest at canonical build + runs-on: ubuntu-latest + needs: [deploy] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7 + with: + enable-cache: true + - name: Download metadata for builds + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + path: builds + pattern: build-* + merge-multiple: true + - name: Login to Docker Hub + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Install regctl + uses: regclient/actions/regctl-installer@main + - name: Point latest to canonical image + run: | + latest_tag="$(uv run dpn latest-key --builds-dir builds/)" + regctl image copy "${IMAGE_NAME}:${latest_tag}" "${IMAGE_NAME}:latest" + release: name: Update versions.json and README.md runs-on: ubuntu-latest - needs: [deploy] + needs: [deploy, tag-latest] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 10937f1..0000000 --- a/Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -FROM python:trixie -LABEL org.opencontainers.image.authors="Nikolai R Kristiansen " - -SHELL ["/bin/bash", "-euo", "pipefail", "-c"] -RUN groupadd --gid 1000 pn && useradd --uid 1000 --gid pn --shell /bin/bash --create-home pn -ENV POETRY_HOME=/usr/local - -RUN NODE_VERSION="$(curl -fsSL https://nodejs.org/dist/latest/SHASUMS256.txt | head -n1 | awk '{ print $2}' | awk -F - '{ print $2}')" \ - ARCH= && dpkgArch="$(dpkg --print-architecture)" \ - && case "${dpkgArch##*-}" in \ - amd64) ARCH='x64';; \ - arm64) ARCH='arm64';; \ - *) echo "unsupported architecture"; exit 1 ;; \ - esac \ - && for key in $(curl -sL https://raw.githubusercontent.com/nodejs/docker-node/HEAD/keys/node.keys); do \ - gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys "$key" || \ - gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$key" ; \ - done \ - && curl -fsSLO --compressed "https://nodejs.org/dist/$NODE_VERSION/node-$NODE_VERSION-linux-$ARCH.tar.xz" \ - && curl -fsSLO --compressed "https://nodejs.org/dist/$NODE_VERSION/SHASUMS256.txt.asc" \ - && gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \ - && grep " node-$NODE_VERSION-linux-$ARCH.tar.xz\$" SHASUMS256.txt | sha256sum -c - \ - && tar -xJf "node-$NODE_VERSION-linux-$ARCH.tar.xz" -C /usr/local --strip-components=1 --no-same-owner \ - && rm "node-$NODE_VERSION-linux-$ARCH.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt \ - && ln -s /usr/local/bin/node /usr/local/bin/nodejs -RUN npm install -g corepack && corepack enable yarn -RUN \ - apt-get update && \ - apt-get upgrade -yqq && \ - pip install -U pip pipenv uv && \ - curl -sSL https://install.python-poetry.org | python - && \ - rm -rf /var/lib/apt/lists/* diff --git a/README.md b/README.md index ee7bbd0..85f8fb8 100644 --- a/README.md +++ b/README.md @@ -139,8 +139,6 @@ Versions are kept up to date using official sources. For Python we scrape the _S ```bash # Pull from Docker Hub docker pull nikolaik/python-nodejs:latest -# Build from GitHub -docker build -t nikolaik/python-nodejs github.com/nikolaik/docker-python-nodejs # Run image docker run -it nikolaik/python-nodejs bash ``` diff --git a/src/docker_python_nodejs/cli.py b/src/docker_python_nodejs/cli.py index d245dc4..ca829e3 100644 --- a/src/docker_python_nodejs/cli.py +++ b/src/docker_python_nodejs/cli.py @@ -10,6 +10,7 @@ from .versions import ( decide_version_combinations, find_new_or_updated, + latest_tag_key, load_build_contexts, load_versions, persist_versions, @@ -23,7 +24,7 @@ class CLIArgs(argparse.Namespace): dry_run: bool distros: list[str] verbose: bool - command: Literal["dockerfile", "build-matrix", "release"] + command: Literal["dockerfile", "build-matrix", "release", "latest-key"] force: bool # build-matrix and release command arg context: str # dockerfile command arg @@ -69,6 +70,11 @@ def run_release(args: CLIArgs) -> None: update_dynamic_readme(versions, supported_python_versions, supported_nodejs_versions, args.dry_run) +def run_latest_key(args: CLIArgs) -> None: + builds = load_build_contexts(args.builds_dir) + print(latest_tag_key(list(builds.values()))) + + def main(args: CLIArgs) -> None: if args.dry_run: logger.debug("Dry run, outputting only.") @@ -79,6 +85,8 @@ def main(args: CLIArgs) -> None: run_build_matrix(args) elif args.command == "release": run_release(args) + elif args.command == "latest-key": + run_latest_key(args) def parse_args() -> CLIArgs: @@ -125,8 +133,19 @@ def parse_args() -> CLIArgs: help="Builds directory with build context JSON files", ) + parser_latest_key = subparsers.add_parser( + "latest-key", + help="Print the built tag that should also be published as latest", + ) + parser_latest_key.add_argument( + "--builds-dir", + type=Path, + required=True, + help="Builds directory with build context JSON files", + ) + cli_args = cast("CLIArgs", parser.parse_args()) - if cli_args.command == "release": + if cli_args.command in {"release", "latest-key"}: if not cli_args.builds_dir.exists(): parser.error(f"Builds directory {cli_args.builds_dir.as_posix()} does not exist") diff --git a/src/docker_python_nodejs/nodejs_versions.py b/src/docker_python_nodejs/nodejs_versions.py index 3ce5db4..ac5c57b 100644 --- a/src/docker_python_nodejs/nodejs_versions.py +++ b/src/docker_python_nodejs/nodejs_versions.py @@ -1,4 +1,5 @@ import datetime +import re from typing import TypedDict import requests @@ -29,6 +30,17 @@ def fetch_node_unofficial_releases() -> list[NodeRelease]: return data +def fetch_latest_nodejs_version() -> str: + url = "https://nodejs.org/dist/latest/SHASUMS256.txt" + res = requests.get(url, timeout=10.0) + res.raise_for_status() + match = re.search(r"node-(v\d+\.\d+\.\d+)-", res.text) + if not match: + msg = "Could not determine latest Node.js version from SHASUMS256.txt" + raise ValueError(msg) + return match.group(1) + + class ReleaseScheduleItem(TypedDict): start: str lts: str diff --git a/src/docker_python_nodejs/versions.py b/src/docker_python_nodejs/versions.py index 8b22f4f..41ca6bc 100644 --- a/src/docker_python_nodejs/versions.py +++ b/src/docker_python_nodejs/versions.py @@ -14,6 +14,7 @@ from .docker_hub import DockerImageDict, DockerTagDict, fetch_tags from .nodejs_versions import ( + fetch_latest_nodejs_version, fetch_node_releases, fetch_node_unofficial_releases, fetch_nodejs_release_schedule, @@ -105,6 +106,17 @@ def _latest_patch(tags: list[DockerTagDict], ver: str, distro: str) -> str | Non return sorted(tags, key=lambda x: Version.parse(x["name"]), reverse=True)[0]["name"] if tags else None +def _latest_python_minor(distro: str) -> str: + python_patch_re = re.compile(rf"^(\d+\.\d+\.\d+)-{distro}$") + tags = [tag["name"] for tag in fetch_tags("python") if python_patch_re.match(tag["name"])] + if not tags: + msg = f"Could not determine latest Python version for distro '{distro}'" + raise ValueError(msg) + + latest_patch = sorted(tags, key=lambda x: Version.parse(x.removesuffix(f"-{distro}")), reverse=True)[0] + return ".".join(latest_patch.removesuffix(f"-{distro}").split(".")[:2]) + + def scrape_supported_python_versions() -> list[SupportedVersion]: """Scrape supported python versions (risky).""" versions = [] @@ -256,6 +268,18 @@ def decide_version_combinations( return version_combinations(nodejs_versions, python_versions) +def latest_tag_key(versions: list[BuildVersion]) -> str: + python_minor = _latest_python_minor(DEFAULT_DISTRO) + node_major = fetch_latest_nodejs_version().removeprefix("v").split(".")[0] + key = f"python{python_minor}-nodejs{node_major}" + + if key not in {version.key for version in versions}: + msg = f"Computed latest tag '{key}' was not part of the current build set" + raise ValueError(msg) + + return key + + def persist_versions(versions: list[BuildVersion], dry_run: bool = False) -> None: if dry_run: logger.debug(versions) diff --git a/tests/test_all.py b/tests/test_all.py index 433e113..73186f8 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -18,6 +18,7 @@ decide_version_combinations, fetch_supported_nodejs_versions, find_new_or_updated, + latest_tag_key, load_build_contexts, scrape_supported_python_versions, ) @@ -289,3 +290,59 @@ def test_find_new_or_updated_with_digest() -> None: res = find_new_or_updated([new], {existing.key: existing}) assert len(res) == 0 + + +def test_latest_tag_key_matches_latest_sources() -> None: + versions = [ + BuildVersion( + key="python3.14-nodejs25", + python="3.14", + python_canonical="3.14.3", + python_image="3.14.3-trixie", + nodejs="25", + nodejs_canonical="25.8.1", + distro="trixie", + platforms=["linux/amd64", "linux/arm64"], + ), + BuildVersion( + key="python3.14-nodejs24-bookworm", + python="3.14", + python_canonical="3.14.3", + python_image="3.14.3-bookworm", + nodejs="24", + nodejs_canonical="24.14.0", + distro="bookworm", + platforms=["linux/amd64", "linux/arm64"], + ), + ] + + with ( + mock.patch("docker_python_nodejs.versions._latest_python_minor", return_value="3.14"), + mock.patch("docker_python_nodejs.versions.fetch_latest_nodejs_version", return_value="v25.8.1"), + ): + assert latest_tag_key(versions) == "python3.14-nodejs25" + + +def test_latest_tag_key_fails_if_canonical_build_is_missing() -> None: + versions = [ + BuildVersion( + key="python3.14-nodejs24", + python="3.14", + python_canonical="3.14.3", + python_image="3.14.3-trixie", + nodejs="24", + nodejs_canonical="24.14.0", + distro="trixie", + platforms=["linux/amd64", "linux/arm64"], + ), + ] + + with ( + mock.patch("docker_python_nodejs.versions._latest_python_minor", return_value="3.14"), + mock.patch("docker_python_nodejs.versions.fetch_latest_nodejs_version", return_value="v25.8.1"), + pytest.raises( + ValueError, + match=r"Computed latest tag 'python3\.14-nodejs25' was not part of the current build set", + ), + ): + latest_tag_key(versions)