diff --git a/.github/workflows/publish-ci-php-images.yml b/.github/workflows/publish-ci-php-images.yml new file mode 100644 index 0000000..1ed239c --- /dev/null +++ b/.github/workflows/publish-ci-php-images.yml @@ -0,0 +1,53 @@ +name: Publish shared CI PHP images + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: codecorn-ci-image-publisher + cancel-in-progress: false + +jobs: + publish: + name: Build, verify and publish GHCR PHP/Playwright toolchains + runs-on: [self-hosted, Linux, X64, codecorn-org] + timeout-minutes: 120 + if: github.repository == 'CodeCornTech/.github' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Verify DKR-01 disk and build prerequisites + shell: bash + run: | + set -euo pipefail + test "$(hostname -s | tr '[:upper:]' '[:lower:]')" = dkr-01 + docker info >/dev/null + bash ci/images/build.sh --dry-run + + - name: Build container images + shell: bash + run: bash ci/images/build.sh --build + + - name: Smoke-test PHP, Composer, Node and Playwright + shell: bash + run: bash ci/images/build.sh --check + + - name: Authenticate only for GHCR publication + if: github.event_name == 'workflow_dispatch' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish the five versioned images + if: github.event_name == 'workflow_dispatch' + shell: bash + run: printf 'PUBLISH\n' | bash ci/images/build.sh --publish diff --git a/ci/images/Dockerfile b/ci/images/Dockerfile new file mode 100644 index 0000000..d46f55c --- /dev/null +++ b/ci/images/Dockerfile @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1 +# Ubuntu Noble + maintained PHP packages (including legacy 8.1), Node 22 and Composer. +# Build once per PHP minor; never install PHP from inside a CI job. +ARG PHP_VERSION=8.3 +FROM node:22-bookworm-slim AS node +FROM composer:2 AS composer +FROM ubuntu:24.04 AS base +ARG PHP_VERSION +LABEL org.opencontainers.image.source="https://github.com/CodeCornTech/.github" +ENV DEBIAN_FRONTEND=noninteractive COMPOSER_ALLOW_SUPERUSER=1 NODE_PATH=/usr/local/lib/node_modules +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + ca-certificates curl gnupg software-properties-common \ + bash git zip unzip jq python3; \ + add-apt-repository -y ppa:ondrej/php; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + "php${PHP_VERSION}-cli" "php${PHP_VERSION}-zip" "php${PHP_VERSION}-bcmath" \ + "php${PHP_VERSION}-intl" "php${PHP_VERSION}-mbstring" \ + "php${PHP_VERSION}-sqlite3" "php${PHP_VERSION}-pgsql" \ + "php${PHP_VERSION}-mysql" "php${PHP_VERSION}-xml" \ + "php${PHP_VERSION}-curl"; \ + ln -s "/usr/bin/php${PHP_VERSION}" /usr/local/bin/php; \ + apt-get clean; \ + rm -rf /var/lib/apt/lists/* +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY --from=node /usr/local/lib/node_modules/ /usr/local/lib/node_modules/ +COPY --from=composer /usr/bin/composer /usr/local/bin/composer +RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ + && ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \ + && php -v && node -v && npm -v && composer --version +WORKDIR /workspace + +FROM base AS ci + +FROM base AS playwright +ARG PLAYWRIGHT_VERSION=1.62.1 +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \ + PLAYWRIGHT_MODULE=/usr/local/lib/node_modules/playwright +RUN npm install --global "playwright@${PLAYWRIGHT_VERSION}" \ + && npx playwright install --with-deps chromium \ + && node -e 'require(process.env.PLAYWRIGHT_MODULE).chromium.executablePath()' diff --git a/ci/images/README.md b/ci/images/README.md new file mode 100644 index 0000000..b8e48c9 --- /dev/null +++ b/ci/images/README.md @@ -0,0 +1,21 @@ +# Shared PHP/Node CI containers on DKR-01 + +Use five versioned images in GHCR: + +- `ghcr.io/codecorntech/ci-php:8.1-node22-v1` +- `ghcr.io/codecorntech/ci-php:8.2-node22-v1` +- `ghcr.io/codecorntech/ci-php:8.3-node22-v1` +- `ghcr.io/codecorntech/ci-php:8.4-node22-v1` +- `ghcr.io/codecorntech/ci-php:8.1-node22-playwright1.62.1-v1` + +These are proposed tags **not yet published**. Images use Ubuntu 24.04 and the same updated Ondrej PHP APT packages as DKR-01 (including the PHP 8.1 compatibility branch), Node 22 from official Node images and Composer 2. This avoids building CI on a long-unpatched official PHP 8.1 image. Playwright installs Chromium once at image build time. + +On DKR-01 (the 32 GB VM) run `bash ci/images/build.sh --dry-run`, then `--build`, then `--check`. Publish only when the check passes, with an authorized `docker login ghcr.io` using a `write:packages` token and `--publish`. + +Private GHCR packages need explicit GitHub Actions access for *both* `CodeCornTech/barbagia-media-manager` and `fgirolami29/wp-life-helper`, or make the image public. Workflow `jobs.container` attempts to pull images; a locally built tag is not a published GHCR package. **Do not merge consumer workflows before image access is confirmed.** + +The build command requires >=6 GiB free on /var/lib/docker and does not remove volumes, images or caches. With only 32 GiB, inspect `docker system df` after builds. Jobs must be trusted: membership in the Docker group is root-equivalent and different Unix accounts sharing the same Docker daemon do **not** isolate runner credentials. Do not add untrusted fork PRs. Do not mount Docker socket into PHP job containers. + +## Manual publisher (no PAT stored on VM) + +After a successful local `--check`, the source PR has been approved and merged to the default branch, and the organization runner is online, manually dispatch `.github/workflows/publish-ci-php-images.yml`. It authenticates using the short-lived workflow `GITHUB_TOKEN` with `packages: write` and publishes the five versioned tags. Do not dispatch until there is sufficient disk free and no other heavy build running. diff --git a/ci/images/build.sh b/ci/images/build.sh new file mode 100644 index 0000000..2ef5626 --- /dev/null +++ b/ci/images/build.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# DKR-01: prepare four PHP/Node toolchains and one Playwright image. +# --dry-run (default), --build, --check, --publish (explicit). +set -Eeuo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" +mode="${1:---dry-run}" +[[ "$#" -le 1 ]] || exit 2 +case "$mode" in --dry-run|--build|--check|--publish) ;; *) echo 'Usage: build.sh --dry-run|--build|--check|--publish' >&2; exit 2 ;; esac +base='ghcr.io/codecorntech/ci-php' +versions=(8.1 8.2 8.3 8.4) +browser_tag="${base}:8.1-node22-playwright1.62.1-v1" +if [[ "$mode" == --dry-run ]]; then + echo 'NO CHANGES: would build/verify four PHP images + one Playwright image' + for v in "${versions[@]}"; do echo "${base}:${v}-node22-v1"; done + echo "$browser_tag" + echo 'No Docker prune. Publish separate and explicit, requires GHCR login.' + df -h /var/lib/docker 2>/dev/null || df -h / + exit 0 +fi +docker info >/dev/null || { echo 'ERROR: cannot access Docker daemon' >&2; exit 1; } +if [[ "$mode" == --build ]]; then + free_kb="$(df -Pk /var/lib/docker | awk 'NR==2 {print $4}')" + [[ "$free_kb" =~ ^[0-9]+$ && "$free_kb" -ge 6291456 ]] || { echo 'ERROR: less than 6GiB disk free' >&2; exit 1; } + for v in "${versions[@]}"; do + tag="${base}:${v}-node22-v1" + docker buildx build --load --pull --progress=plain --target ci -f Dockerfile --build-arg "PHP_VERSION=$v" -t "$tag" . + done + docker buildx build --load --progress=plain --target playwright -f Dockerfile \ + --build-arg 'PHP_VERSION=8.1' \ + --build-arg 'PLAYWRIGHT_VERSION=1.62.1' -t "$browser_tag" . + echo 'Build finished; run --check before --publish.' +elif [[ "$mode" == --check ]]; then + for v in "${versions[@]}"; do + tag="${base}:${v}-node22-v1" + docker image inspect "$tag" >/dev/null || { echo "MISSING: $tag"; exit 1; } + major="${v%%.*}"; minor="${v#*.}" + docker run --rm --pull=never --entrypoint sh "$tag" -ec \ + "php -r 'exit(PHP_MAJOR_VERSION===$major && PHP_MINOR_VERSION===$minor ? 0 : 1);' && php -m | grep -iq zip && node --version | grep -q '^v22[.]' && composer --version" + echo "OK: $tag" + done + docker image inspect "$browser_tag" >/dev/null || { echo "MISSING: $browser_tag"; exit 1; } + docker run --rm --pull=never --entrypoint node "$browser_tag" -e \ + 'const p = require(process.env.PLAYWRIGHT_MODULE); const fs = require("node:fs"); if (!fs.existsSync(p.chromium.executablePath())) process.exit(1);' + echo "OK: $browser_tag" + docker system df + echo 'CHECK PASSED' +else + echo 'Requires existing docker login ghcr.io (write:packages).' + read -r -p 'Type PUBLISH to upload all five images: ' confirm + [[ "$confirm" == PUBLISH ]] || { echo 'Aborted'; exit 1; } + for v in "${versions[@]}"; do + tag="${base}:${v}-node22-v1" + docker image inspect "$tag" >/dev/null + docker push "$tag" + done + docker image inspect "$browser_tag" >/dev/null + docker push "$browser_tag" + echo 'Verify GHCR visibility/access from BOTH GitHub owners before CI migration.' +fi diff --git a/ci/runners/patch-register-v2.py b/ci/runners/patch-register-v2.py new file mode 100644 index 0000000..c31d3ae --- /dev/null +++ b/ci/runners/patch-register-v2.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Fix runner v2's incorrect GitHub SHA-256 release note parser, safely.""" +from pathlib import Path +import subprocess +import sys + +if len(sys.argv) != 2: + raise SystemExit("Usage: python3 patch-register-v2.py /path/to/cc-dkr01-register-runners-v2.sh") +p = Path(sys.argv[1]).resolve() +source = p.read_text() +start_marker = 'read -r download_url expected_sha archive_name < <(python3 -c' +end_marker = '[[ "$expected_sha" =~ ^[a-f0-9]{64}$ ]]' +if start_marker not in source or end_marker not in source: + raise SystemExit("STOP: expected v2 checksum parser not found; no changes") +start = source.index(start_marker) +end = source.index(end_marker, start) +replacement = '''resolved="$(python3 -c '\nimport json, re, sys\ntry:\n meta = json.load(sys.stdin)\nexcept ValueError as exc:\n raise SystemExit(f"Invalid GitHub release metadata: {exc}")\npattern = re.compile(r"actions-runner-linux-x64-[0-9]+(?:\\.[0-9]+)+\\.tar\\.gz")\nassets = [a for a in meta.get("assets", []) if pattern.fullmatch(a.get("name", ""))]\nif len(assets) != 1:\n raise SystemExit("Expected exactly one GitHub runner Linux x64 archive")\na = assets[0]\nname = a["name"]\nurl = a.get("browser_download_url", "")\nif not url.startswith("https://github.com/actions/runner/releases/download/"):\n raise SystemExit("Unexpected GitHub runner download URL")\ndig = re.fullmatch(r"sha256:([a-fA-F0-9]{64})", a.get("digest") or "")\nbody = meta.get("body") or ""\nnotes = re.search(re.escape(name)+r"[\\x60\\s:|–-]+(?:SHA-?256[\\s:=]+)?([a-fA-F0-9]{64})(?![a-fA-F0-9])", body, re.I)\nif dig and notes and dig.group(1).lower() != notes.group(1).lower():\n raise SystemExit("Checksum disagreement between API digest and release notes")\nsha = dig or notes\nif sha is None:\n raise SystemExit("No official SHA256 for Linux runner; refusing to proceed")\nprint(f"{url}\\t{sha.group(1).lower()}\\t{name}")\n' <<< "$metadata")" || fail 'Release information/checksum incomplete'\nIFS=$'\\t' read -r download_url expected_sha archive_name <<< "$resolved"\n''' +updated = source[:start] + replacement + source[end:] +backup = p.with_name(p.name + ".before-digest-fix") +if backup.exists(): + raise SystemExit("STOP: backup already exists; refusing to overwrite") +subprocess.run(["bash", "-n"], input=updated, text=True, check=True) +backup.write_text(source) +p.write_text(updated) +print(f"PATCHED: {p}\nBACKUP: {backup}\nVALIDATED: bash -n passed")