Skip to content
Closed
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
52 changes: 47 additions & 5 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 }}


Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
32 changes: 0 additions & 32 deletions Dockerfile

This file was deleted.

2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
23 changes: 21 additions & 2 deletions src/docker_python_nodejs/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .versions import (
decide_version_combinations,
find_new_or_updated,
latest_tag_key,
load_build_contexts,
load_versions,
persist_versions,
Expand All @@ -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
Expand Down Expand Up @@ -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.")
Expand All @@ -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:
Expand Down Expand Up @@ -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")

Expand Down
12 changes: 12 additions & 0 deletions src/docker_python_nodejs/nodejs_versions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import datetime
import re
from typing import TypedDict

import requests
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions src/docker_python_nodejs/versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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)
Expand Down
57 changes: 57 additions & 0 deletions tests/test_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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)