diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f1a548e..425528c 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -5,6 +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: ${{ inputs.image_name || vars.IMAGE_NAME || 'nikolaik/python-nodejs' }} jobs: generate-matrix: @@ -23,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 }} @@ -59,12 +74,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 +90,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 @@ -92,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)