diff --git a/.github/workflows/mirror-node.yml b/.github/workflows/mirror-node.yml new file mode 100644 index 000000000..879f4957d --- /dev/null +++ b/.github/workflows/mirror-node.yml @@ -0,0 +1,136 @@ +name: Mirror Node Base Image (Docker Hub to GHCR) + +# Keeps a copy of the official node images our Dockerfiles build FROM in GHCR. +# Anonymous pulls from Docker Hub / ECR Public are rate limited per source IP, +# and GitHub-hosted runners share IPs, so CI builds were failing with 429s. +# Public GHCR packages pull without credentials, so PR builds (including from +# forks) need no secrets. +# +# The tags to mirror are read from the Dockerfiles themselves: any reference to +# ghcr.io//node: is copied from docker.io/library/node:. Bumping +# the Node version in a Dockerfile and pushing it mirrors the new tag +# automatically. The daily run picks up upstream rebuilds (OS security patches) +# of the same tag. +# +# NOTE: the first push creates the ghcr.io//node package as private. +# Make it public once in the org's package settings so builds can pull it +# anonymously. + +permissions: + contents: read + packages: write + +on: + schedule: + - cron: "17 4 * * *" + push: + paths: + - "Dockerfile*" + - ".github/workflows/mirror-node.yml" + workflow_dispatch: + inputs: + tags: + description: "Extra node tags to mirror, space separated (e.g. 24.23.0-slim)" + required: false + default: "" + +concurrency: + group: mirror-node + cancel-in-progress: false + +env: + SOURCE_IMAGE: docker.io/library/node + DEST_IMAGE: ghcr.io/${{ github.repository_owner }}/node + +jobs: + mirror: + # Don't run (and push to their own GHCR) in forks + if: github.repository_owner == 'fosrl' + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + REGISTRY_AUTH_FILE: ${{ runner.temp }}/containers/auth.json + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install skopeo + run: | + sudo apt-get update -y + sudo apt-get install -y skopeo + skopeo --version + + - name: Log in to registries + env: + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} + DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + run: | + set -euo pipefail + mkdir -p "$(dirname "$REGISTRY_AUTH_FILE")" + echo "$GHCR_TOKEN" | skopeo login ghcr.io -u "${{ github.actor }}" --password-stdin + # Authenticated Docker Hub pulls avoid the shared-IP anonymous limit. + # This only runs on trusted refs (schedule/push/dispatch), never PRs. + if [ -n "$DOCKER_HUB_USERNAME" ] && [ -n "$DOCKER_HUB_ACCESS_TOKEN" ]; then + echo "$DOCKER_HUB_ACCESS_TOKEN" | skopeo login docker.io -u "$DOCKER_HUB_USERNAME" --password-stdin + fi + + - name: Collect tags + env: + EXTRA_TAGS: ${{ inputs.tags }} + run: | + set -euo pipefail + { + grep -hoE "${DEST_IMAGE}:[A-Za-z0-9._-]+" Dockerfile* | sed "s|^${DEST_IMAGE}:||" || true + for t in ${EXTRA_TAGS:-}; do echo "$t"; done + } | sort -u > tags.txt + if [ ! -s tags.txt ]; then + echo "::error ::No ${DEST_IMAGE}: references found in Dockerfiles" + exit 1 + fi + echo "Tags to mirror:" + cat tags.txt + + - name: Mirror tags + run: | + set -euo pipefail + errs=0 + while read -r tag; do + if ! [[ "$tag" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "::warning ::Skipping invalid tag '${tag}'" + errs=$((errs+1)); continue + fi + + src="${SOURCE_IMAGE}:${tag}" + dst="${DEST_IMAGE}:${tag}" + + if ! skopeo inspect --raw --retry-times 3 "docker://${src}" > src.json; then + echo "::warning title=Inspect failed::${src}" + errs=$((errs+1)); continue + fi + src_digest="$(skopeo manifest-digest src.json)" + + dst_digest="" + if skopeo inspect --raw --retry-times 3 "docker://${dst}" > dst.json 2>/dev/null; then + dst_digest="$(skopeo manifest-digest dst.json)" + fi + + if [ "$src_digest" = "$dst_digest" ]; then + echo "Up to date: ${dst} (${src_digest})" + continue + fi + + echo "==> Copy ${src} (${src_digest}) -> ${dst}" + # --all keeps every architecture; --preserve-digests keeps the + # mirror byte-identical so it can be pinned by the upstream digest. + if ! skopeo copy --all --preserve-digests --retry-times 3 \ + "docker://${src}" "docker://${dst}"; then + echo "::warning title=Copy failed::${src}" + errs=$((errs+1)) + fi + done < tags.txt + + if [ "$errs" -gt 0 ]; then + echo "::error ::${errs} tag(s) failed to mirror" + exit 1 + fi diff --git a/Dockerfile b/Dockerfile index aff11ae4e..01406a829 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,8 @@ -# FROM node:24.22.1-slim AS base -FROM public.ecr.aws/docker/library/node:24.22.1-slim AS base +# Mirror of docker.io/library/node, kept in sync by +# .github/workflows/mirror-node.yml (avoids Docker Hub / ECR pull rate limits) +ARG NODE_IMAGE=ghcr.io/fosrl/node:24.22.1-slim + +FROM ${NODE_IMAGE} AS base WORKDIR /app @@ -35,8 +38,7 @@ RUN npm prune --omit=dev && \ # Real files should be present for saas builds, placeholders for oss builds RUN touch /app/GeoLite2-Country.mmdb /app/GeoLite2-ASN.mmdb -# FROM node:24.22.1-slim AS runner -FROM public.ecr.aws/docker/library/node:24.22.1-slim AS runner +FROM ${NODE_IMAGE} AS runner WORKDIR /app diff --git a/Dockerfile.dev b/Dockerfile.dev index 7874b6c55..2a68e578a 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,6 @@ -FROM node:24.22.1-alpine +# Mirror of docker.io/library/node, kept in sync by +# .github/workflows/mirror-node.yml +FROM ghcr.io/fosrl/node:24.22.1-alpine WORKDIR /app