diff --git a/.github/actions/build-php-images/action.yml b/.github/actions/build-php-images/action.yml index 194a628d..d10a9901 100644 --- a/.github/actions/build-php-images/action.yml +++ b/.github/actions/build-php-images/action.yml @@ -32,6 +32,11 @@ inputs: passes exactly one platform here so that per-platform digest artifacts can be uploaded and merged independently. required: true + devpanel_version: + description: > + Optional devpanel/dp-cli release tag to install (for example "v1.2.3"). + Empty installs the latest release for the platform being built. + default: '' repo: description: > Docker Hub repository to push images to (e.g. "devpanel/php"). @@ -113,8 +118,37 @@ runs: username: ${{ inputs.ghcr_token && inputs.ghcr_user || github.actor }} password: ${{ inputs.ghcr_user && inputs.ghcr_token || inputs.github_token }} + # Install the devpanel CLI that matches the image platform. Native + # runners can then execute `devpanel version` during the image build. + - name: Install platform-matching DevPanel CLI + shell: bash + working-directory: ${{ github.workspace }} + env: + PLATFORM: ${{ inputs.platform }} + GH_TOKEN: ${{ inputs.github_token }} + DEVPANEL_TOKEN_FALLBACK: ${{ inputs.ghcr_token }} + DEVPANEL_VERSION: ${{ inputs.devpanel_version }} + run: | + set -euo pipefail + platform="${PLATFORM// /}" + case "${platform}" in + */arm64/v8) platform="${platform%/v8}" ;; + */amd64/v1) platform="${platform%/v1}" ;; + esac + case "${platform}" in + linux/arm64*) arch=arm64 ;; + linux/amd64*) arch=amd64 ;; + *) + echo "::error::No devpanel binary mapping for platform '${PLATFORM}'." + exit 1 + ;; + esac + bash .github/scripts/fetch-devpanel.sh "${arch}" "base/bin/devpanel-${arch}" + + # QEMU is required only when this runner cannot execute the target + # architecture. Native amd64 and arm64 builds skip it. - name: Set up QEMU - if: ${{ !(runner.arch == 'X64' && startsWith(inputs.platform, 'linux/amd64')) }} + if: ${{ (runner.arch == 'X64' && !startsWith(inputs.platform, 'linux/amd64')) || (runner.arch == 'ARM64' && !startsWith(inputs.platform, 'linux/arm64')) }} uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx diff --git a/.github/scripts/fetch-devpanel.sh b/.github/scripts/fetch-devpanel.sh new file mode 100755 index 00000000..e781994d --- /dev/null +++ b/.github/scripts/fetch-devpanel.sh @@ -0,0 +1,338 @@ +#!/usr/bin/env bash +# Download the devpanel CLI for one architecture from devpanel/dp-cli. +# +# Usage: +# fetch-devpanel.sh +# fetch-devpanel.sh --self-test +# +# DEVPANEL_VERSION Release tag. Empty selects the latest release. +# GH_TOKEN or GITHUB_TOKEN Token used to read https://github.com/devpanel/dp-cli. +# DEVPANEL_TOKEN_FALLBACK Tried when the primary token cannot read releases. +set -euo pipefail + +asset_filter() { + cat <<'EOF' +def arch_ok: + if $arch == "amd64" then test("(^|[^A-Za-z0-9])(amd64|x86_64)([^A-Za-z0-9]|$)"; "i") + elif $arch == "arm64" then test("(^|[^A-Za-z0-9])(arm64|aarch64)([^A-Za-z0-9]|$)"; "i") + else false + end; +def skip: + test("\\.(sha256|sha512|sum|txt|md|sig|pem|sbom|json|yml|yaml)$"; "i") + or test("checksum|provenance|intoto"; "i"); +def other_os: + test("darwin|macos|windows|freebsd|android|\\.exe$"; "i"); +( + [ .assets[]? + | select(.name | skip | not) + | select(.name | other_os | not) + | select(.name | arch_ok) + ] + | sort_by( + (if (.name | test("musl|alpine"; "i")) then 1 else 0 end), + (if (.name | test("devpanel"; "i")) then 0 else 1 end), + (if (.name | test("\\.(tar\\.gz|tgz|tar\\.xz|txz|zip|deb)$"; "i")) then 1 else 0 end), + (.name | length), + .name + ) + | .[0] +) as $asset +| if $asset == null then empty else [$asset.id, $asset.name, $asset.url] | @tsv end +EOF +} + +elf_matches() { + local desc="$1" + local want="$2" + local lower + lower=$(printf '%s' "$desc" | tr '[:upper:]' '[:lower:]') + case "$want" in + amd64) [[ "$lower" == *x86-64* || "$lower" == *x86_64* ]] ;; + arm64) [[ "$lower" == *aarch64* ]] ;; + *) return 1 ;; + esac +} + +extract_into() { + local asset_path="$1" + local extract_dir="$2" + local kind + kind=$(file -b "$asset_path") + mkdir -p "$extract_dir" + case "$kind" in + *"gzip compressed"*) + if tar -tzf "$asset_path" >/dev/null 2>&1; then + tar -xzf "$asset_path" -C "$extract_dir" + else + gzip -dc "$asset_path" > "${extract_dir}/devpanel" + fi + ;; + *"XZ compressed"*) + tar -xJf "$asset_path" -C "$extract_dir" + ;; + *"Zip archive"*) + unzip -q "$asset_path" -d "$extract_dir" + ;; + *"Debian binary package"*) + dpkg-deb -x "$asset_path" "$extract_dir" + ;; + "ELF "*) + cp "$asset_path" "${extract_dir}/devpanel" + ;; + *) + echo "ERROR: Unrecognized devpanel asset type: ${kind}" >&2 + return 1 + ;; + esac +} + +choose_elf() { + local root="$1" + local want="$2" + local named="" fallback="" candidate base desc + while IFS= read -r candidate; do + [[ -f "$candidate" ]] || continue + desc=$(file -b "$candidate") + elf_matches "$desc" "$want" || continue + base=$(basename "$candidate") + case "$base" in + devpanel|dp-cli|dp) + named=$candidate + break + ;; + esac + if [[ -z "$fallback" ]]; then + fallback=$candidate + fi + done < <(find "$root" -type f -print) + if [[ -n "$named" ]]; then + printf '%s\n' "$named" + elif [[ -n "$fallback" ]]; then + printf '%s\n' "$fallback" + else + return 1 + fi +} + +usage() { + cat <<'EOF' +Usage: fetch-devpanel.sh + fetch-devpanel.sh --self-test +EOF +} + +self_test() ( + set -euo pipefail + local json selected name pack archive extract found + json=$(mktemp) + trap 'rm -f "$json"' EXIT + cat >"$json" <<'EOF' +{ "assets": [ + {"id": 1, "name": "checksums.txt", "url": "https://example.test/checksums"}, + {"id": 2, "name": "devpanel-linux-amd64.tar.gz", "url": "https://example.test/amd64-tar"}, + {"id": 3, "name": "devpanel-linux-amd64", "url": "https://example.test/amd64"}, + {"id": 4, "name": "devpanel-linux-arm64", "url": "https://example.test/arm64"}, + {"id": 5, "name": "devpanel-linux-musl-amd64", "url": "https://example.test/musl"}, + {"id": 6, "name": "devpanel-darwin-arm64", "url": "https://example.test/darwin"}, + {"id": 7, "name": "dp-cli_1.4.0_linux_x86_64.tar.gz", "url": "https://example.test/x86"} +]} +EOF + selected=$(jq -er --arg arch amd64 "$(asset_filter)" "$json") + name=$(printf '%s' "$selected" | cut -f2) + [[ "$name" == "devpanel-linux-amd64" ]] || { + echo "amd64 selection failed: ${name}" >&2 + exit 1 + } + selected=$(jq -er --arg arch arm64 "$(asset_filter)" "$json") + name=$(printf '%s' "$selected" | cut -f2) + [[ "$name" == "devpanel-linux-arm64" ]] || { + echo "arm64 selection failed: ${name}" >&2 + exit 1 + } + + cat >"$json" <<'EOF' +{ "assets": [ + {"id": 9, "name": "dp-cli_1.4.0_linux_arm64.tar.gz", "url": "https://example.test/arm-tar"}, + {"id": 8, "name": "dp-cli_1.4.0_linux_amd64.tar.gz", "url": "https://example.test/amd-tar"} +]} +EOF + selected=$(jq -er --arg arch arm64 "$(asset_filter)" "$json") + name=$(printf '%s' "$selected" | cut -f2) + [[ "$name" == "dp-cli_1.4.0_linux_arm64.tar.gz" ]] || { + echo "archive selection failed: ${name}" >&2 + exit 1 + } + + if ! elf_matches "ELF 64-bit LSB executable, x86-64, statically linked" amd64; then + echo "amd64 ELF match failed" >&2 + exit 1 + fi + if elf_matches "ELF 64-bit LSB executable, x86-64, statically linked" arm64; then + echo "arm64 ELF match should reject x86-64" >&2 + exit 1 + fi + if ! elf_matches "ELF 64-bit LSB executable, ARM aarch64, statically linked" arm64; then + echo "arm64 ELF match failed" >&2 + exit 1 + fi + + pack=$(mktemp -d) + archive=$(mktemp) + extract=$(mktemp -d) + cp /bin/true "${pack}/devpanel" + tar -C "$pack" -czf "$archive" devpanel + extract_into "$archive" "$extract" + found=$(choose_elf "$extract" amd64) + [[ "$(basename "$found")" == "devpanel" ]] || { + echo "extract selection failed: ${found}" >&2 + exit 1 + } + rm -rf "$pack" "$archive" "$extract" + echo "self-test ok" +) + +http_get() { + local token="$1" + local accept="$2" + local url="$3" + local out="$4" + local -a args + args=( + -sS -L --retry 5 --retry-all-errors --connect-timeout 20 + -H "Accept: ${accept}" + -H "User-Agent: devpanel-php-build" + -o "$out" + -w "%{http_code}" + ) + if [[ -n "$token" ]]; then + args+=(-H "Authorization: Bearer ${token}") + fi + curl "${args[@]}" "$url" +} + +fetch_release() { + local out="$1" + local -a tokens=() + local token status + if [[ -n "${GH_TOKEN:-}" ]]; then + tokens+=("${GH_TOKEN}") + fi + if [[ -n "${DEVPANEL_TOKEN_FALLBACK:-}" && "${DEVPANEL_TOKEN_FALLBACK}" != "${GH_TOKEN:-}" ]]; then + tokens+=("${DEVPANEL_TOKEN_FALLBACK}") + fi + tokens+=("") + status="" + for token in "${tokens[@]}"; do + status=$(http_get "$token" "application/vnd.github+json" "$release_url" "$out" || true) + if [[ "$status" == "200" ]]; then + working_token=$token + return 0 + fi + done + echo "ERROR: GitHub API returned HTTP ${status:-unknown} for ${release_url}." >&2 + if [[ -s "$out" ]]; then + jq -r '.message // empty' "$out" >&2 || true + fi + return 1 +} + +die_or_keep_existing() { + local reason="$1" + local desc + if [[ -n "$dest" && -f "$dest" ]]; then + desc=$(file -b "$dest") + if elf_matches "$desc" "$arch"; then + echo "::warning::${reason} Using the existing ${dest}." + exit 0 + fi + echo "ERROR: Existing ${dest} is not a ${arch} executable (${desc})." >&2 + fi + echo "ERROR: ${reason}" >&2 + echo "::error::${reason}" + exit 1 +} + +if [[ "${1:-}" == "--self-test" ]]; then + self_test + exit +fi + +if [[ $# -ne 2 ]]; then + usage >&2 + exit 1 +fi + +arch=$1 +dest=$2 +case "$arch" in + amd64|arm64) ;; + *) + echo "ERROR: Architecture must be amd64 or arm64 (got '${arch}')." >&2 + exit 1 + ;; +esac + +if [[ -z "${GH_TOKEN:-}" && -n "${GITHUB_TOKEN:-}" ]]; then + GH_TOKEN=$GITHUB_TOKEN +fi + +if [[ -n "${DEVPANEL_VERSION:-}" ]]; then + if [[ ! "$DEVPANEL_VERSION" =~ ^[A-Za-z0-9._+-]+$ ]]; then + echo "ERROR: DEVPANEL_VERSION contains unsupported characters." >&2 + exit 1 + fi + release_url="https://api.github.com/repos/devpanel/dp-cli/releases/tags/${DEVPANEL_VERSION}" +else + release_url="https://api.github.com/repos/devpanel/dp-cli/releases/latest" +fi + +for cmd in curl jq file tar gzip; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "ERROR: Required command not found: ${cmd}" >&2 + exit 1 + fi +done + +work="" +working_token="" +trap '[[ -n "${work}" ]] && rm -rf "${work}"' EXIT +work=$(mktemp -d) +release_json="${work}/release.json" + +if ! fetch_release "$release_json"; then + die_or_keep_existing "Unable to read devpanel/dp-cli releases. Grant the workflow token access to https://github.com/devpanel/dp-cli." +fi + +selection="" +if ! selection=$(jq -er --arg arch "$arch" "$(asset_filter)" "$release_json"); then + echo "Release assets:" >&2 + jq -r '.assets[]?.name' "$release_json" >&2 || true + die_or_keep_existing "No ${arch} Linux devpanel asset was found in the dp-cli release." +fi + +IFS=$'\t' read -r asset_id asset_name asset_url <<<"$selection" +if [[ -z "${asset_url}" || -z "${asset_name}" ]]; then + die_or_keep_existing "Release asset metadata for ${arch} was incomplete." +fi + +asset_path="${work}/asset" +download_status=$(http_get "$working_token" "application/octet-stream" "$asset_url" "$asset_path" || true) +if [[ "$download_status" != "200" ]]; then + die_or_keep_existing "Download of ${asset_name} failed with HTTP ${download_status:-unknown}." +fi + +extract_dir="${work}/extract" +if ! extract_into "$asset_path" "$extract_dir"; then + die_or_keep_existing "Could not unpack ${asset_name}." +fi + +binary_path="" +if ! binary_path=$(choose_elf "$extract_dir" "$arch"); then + die_or_keep_existing "No ${arch} executable was found inside ${asset_name}." +fi + +mkdir -p "$(dirname "$dest")" +mv -f "$binary_path" "$dest" +chmod 0755 "$dest" +tag=$(jq -r '.tag_name // "unknown"' "$release_json") +echo "Installed devpanel ${tag} asset ${asset_id} (${asset_name}) for ${arch} at ${dest}" diff --git a/AGENTS.md b/AGENTS.md index abbe2022..c99863b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Images are published to Docker Hub as `devpanel/php:-` (e.g. ` ``` base/ # Shared base Dockerfile (PHP + Apache) Dockerfile - bin/ # DevPanel CLI binary + bin/ # devpanel CLI binaries named devpanel-$arch drush/ # Drush version directories (drush7–drush11) scripts/ # Apache startup scripts templates/ # Apache/PHP config templates @@ -150,3 +150,13 @@ Baseline JSON files in `tests/baselines/` track pre-existing lint violations per - If a change applies to all versions (e.g. bumping a shared tool version), update the shared Dockerfile(s) in `base/`, `secure/`, or `advance/` — plus any per-version overrides that pin a different value. - Describe which image tags are affected in the PR description. - Verify the Docker build locally before opening a PR: `docker buildx bake --no-push` (uses `docker-bake.hcl`), or run `./test.sh` for full linting and build tests. + +## Cursor Cloud specific instructions + +The Cloud Agent VM has no systemd. Environment install puts Docker, Buildx, shellcheck, yamllint, and hadolint 2.12.0 on the machine. Environment start launches `dockerd` and selects the Buildx builder `devpanel` (`docker-container` driver). `./test.sh build` exports cache from `docker-bake.hcl`, and the default `docker` driver rejects that export. + +- If `docker info` fails, read `/tmp/cursor/start-user/start-user.log` and `/var/log/dockerd.log`. `ubuntu` is in the `docker` group. A shell that was opened before that membership existed needs a new login. +- Fast checks: `./test.sh yaml shell dockerfile`. +- One-version image check: `./test.sh build run --version 8.3`. That builds `base`, `secure`, and `advance`, then runs `tests/run-dockerfile.sh`. `./test.sh build` with no `--version` builds every version directory and takes much longer. +- Local test builds point registry cache at an unreachable `localhost:0` address. Those cache-export errors are ignored. Images are still loaded as `devpanel-php-test:-` for the run suite, which removes them afterward. +- `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` are only needed to publish. AMD64 builds use the committed `base/bin/devpanel-amd64` binary. An ARM64 build downloads `base/bin/devpanel-arm64` and needs `GH_TOKEN` with read access to `devpanel/dp-cli`. diff --git a/README.md b/README.md index 72b29456..bcfbf937 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,10 @@ Each PHP version ships three layered variants. Release candidate tags end with `-rc` (e.g. `8.3-base-rc`). -The bundled legacy `devpanel` CLI is AMD64-only, including in ARM64 images. -Executing it on an ARM64 host requires AMD64 emulation on that host; CI runner -emulation is not included in the published images. +Each image includes the `devpanel` CLI for its own architecture. Publish +builds install that binary from +[devpanel/dp-cli](https://github.com/devpanel/dp-cli): AMD64 images get the +amd64 build and ARM64 images get the arm64 build. Available PHP versions: **7.4 · 8.0 · 8.1 · 8.2 · 8.3** @@ -184,6 +185,11 @@ removed). To remove all `devpanel-php-test:*` images at once, run: bash tests/cleanup-test-images.sh ``` +AMD64 builds use the committed `base/bin/devpanel-amd64` binary. An ARM64 +build downloads `base/bin/devpanel-arm64` from +[devpanel/dp-cli](https://github.com/devpanel/dp-cli) when that file is not +already present. `GH_TOKEN` must be able to read that repository. + Build tests take several minutes per PHP version because the images download packages (code-server, composer, etc.). Run a single version during development to keep the feedback loop short: @@ -286,9 +292,10 @@ for setup instructions. - The build workflow is `docker-build.yml`, which handles both push-triggered and manual dispatch builds. It runs a detect → build (per-platform, parallel) → merge pipeline. ARM64 builds use native `ubuntu-26.04-arm` - runners; AMD64 builds use `ubuntu-26.04`. QEMU is set up on ARM runners - to execute the bundled AMD64 DevPanel CLI, and for cross-platform builds. - Native AMD64 builds skip QEMU. + runners; AMD64 builds use `ubuntu-26.04`. Each build installs the + `devpanel` CLI from `devpanel/dp-cli` for the platform it is building. + QEMU is set up only when the runner architecture differs from that + platform. Native AMD64 and ARM64 builds skip QEMU. - The `advance` variant depends on `secure`, which depends on `base`. The build chain is enforced via the bake target dependency graph. diff --git a/TODO.md b/TODO.md index a7fa5f1f..f7704f78 100644 --- a/TODO.md +++ b/TODO.md @@ -102,3 +102,17 @@ Use this file to track work that needs to be done. Lint scripts automatically ap - Legacy images use a PEAR release with HTTP chunked response decoding. - OPcache remains loaded on every PHP version without enabling a nonexistent PHP 8.5 shared module. - Focused checks pass; any remaining build or CI blockers are explicitly reported. + +# Task: Install the platform-matching DevPanel CLI + +## Steps +- [x] Copy `base/bin/devpanel-${TARGETARCH}` in the shared image build. +- [x] Download the matching release asset from `devpanel/dp-cli` before publish builds. +- [x] Register QEMU only when the runner architecture differs from the target platform. +- [x] Document the architecture-specific CLI and the native-build QEMU skip. +- [x] Run YAML, shell, and Dockerfile lint; self-test asset selection and the amd64 fallback. + +## Definition of Done +- ARM64 image builds install an arm64 `devpanel` binary from `devpanel/dp-cli`. +- Native AMD64 and ARM64 builds do not register QEMU. Cross-architecture builds still do. +- YAML, shell, and Dockerfile lint baselines match, and the changed files contain no secrets. diff --git a/base/Dockerfile b/base/Dockerfile index 6c243dcb..a631d223 100644 --- a/base/Dockerfile +++ b/base/Dockerfile @@ -294,7 +294,11 @@ RUN a2enmod rewrite remoteip headers # Copy shared assets from the 'common' build context (./base/ directory). # Per-version Dockerfiles may overlay version-specific files on top. -COPY --from=common bin/devpanel /usr/local/bin/devpanel +# devpanel-$TARGETARCH is the CLI build for this image (amd64 or arm64), +# published by https://github.com/devpanel/dp-cli. Running it here requires +# the binary to match the image architecture. +ARG TARGETARCH +COPY --from=common bin/devpanel-${TARGETARCH} /usr/local/bin/devpanel RUN chmod +x /usr/local/bin/devpanel && devpanel version COPY --from=common templates /templates COPY --from=common scripts /scripts diff --git a/base/bin/devpanel b/base/bin/devpanel-amd64 similarity index 100% rename from base/bin/devpanel rename to base/bin/devpanel-amd64 diff --git a/tests/build-dockerfile.sh b/tests/build-dockerfile.sh index 2e08c6b4..1e6a5e9f 100755 --- a/tests/build-dockerfile.sh +++ b/tests/build-dockerfile.sh @@ -105,6 +105,20 @@ case "$NORMALIZED_PLATFORM" in esac PLATFORM_KEY="${NORMALIZED_PLATFORM//\//-}" +# The image copies base/bin/devpanel-$arch. The amd64 binary is committed. +# Download a missing binary (the arm64 build, on an arm64 host) from dp-cli. +cli_arch="" +case "$NORMALIZED_PLATFORM" in + linux/amd64*) cli_arch="amd64" ;; + linux/arm64*) cli_arch="arm64" ;; +esac +if [[ -n "$cli_arch" && ! -f "${REPO_ROOT}/base/bin/devpanel-${cli_arch}" ]]; then + echo "Fetching ${cli_arch} devpanel CLI from devpanel/dp-cli..." + GH_TOKEN="${GH_TOKEN:-${GITHUB_TOKEN:-}}" \ + bash "${REPO_ROOT}/.github/scripts/fetch-devpanel.sh" \ + "$cli_arch" "${REPO_ROOT}/base/bin/devpanel-${cli_arch}" +fi + # --------------------------------------------------------------------------- # Build using docker-bake.hcl in test mode # ---------------------------------------------------------------------------