diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index eab96d2e..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "npm" - directory: "/docs" - schedule: - interval: "daily" - allow: - - dependency-name: "@serversideup/project-switcher-bar" - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "monthly" - groups: - github-actions: - patterns: - - "*" diff --git a/.github/workflows/action_publish-images-beta.yml b/.github/workflows/action_publish-images-beta.yml index 79fb16ff..11de8353 100644 --- a/.github/workflows/action_publish-images-beta.yml +++ b/.github/workflows/action_publish-images-beta.yml @@ -55,8 +55,18 @@ jobs: with: ref: ${{ needs.release.outputs.tag }} - build: + base: needs: [release, setup] + uses: ./.github/workflows/service_build-php-base.yml + permissions: + contents: read + id-token: write + with: + matrix: ${{ needs.setup.outputs.base-matrix }} + ref: ${{ needs.release.outputs.tag }} + + build: + needs: [release, setup, base] name: build ${{ matrix.variation }} strategy: fail-fast: false @@ -73,10 +83,9 @@ jobs: registry-repositories: "docker.io/serversideup/php,ghcr.io/serversideup/php" tag-prefix: "beta" release-type: "testing" - secrets: inherit test: - needs: [release, setup, build] + needs: [release, setup, base, build] if: always() && needs.setup.result == 'success' uses: ./.github/workflows/service_test-images.yml permissions: @@ -96,7 +105,9 @@ jobs: id-token: write with: ref: ${{ needs.release.outputs.tag }} - secrets: inherit + secrets: + DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} + DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} report: needs: [release, setup, build, test, publish] @@ -106,4 +117,3 @@ jobs: contents: read with: matrix: ${{ needs.setup.outputs.matrix }} - ref: ${{ needs.release.outputs.tag }} diff --git a/.github/workflows/action_publish-images-production.yml b/.github/workflows/action_publish-images-production.yml index e411c535..34ba5f7c 100644 --- a/.github/workflows/action_publish-images-production.yml +++ b/.github/workflows/action_publish-images-production.yml @@ -3,11 +3,17 @@ run-name: >- Production images (${{ github.event.release.tag_name || (github.event_name == 'schedule' && 'weekly rebuild') || + inputs.reason || 'manual rebuild' }}) on: workflow_dispatch: + inputs: + reason: + description: 'Shown in the run title (example: PHP 8.5.13 release). Left empty, the run is a "manual rebuild".' + type: string + required: false release: types: [released] schedule: @@ -55,8 +61,18 @@ jobs: with: ref: ${{ needs.release.outputs.tag }} - build: + base: needs: [release, setup] + uses: ./.github/workflows/service_build-php-base.yml + permissions: + contents: read + id-token: write + with: + matrix: ${{ needs.setup.outputs.base-matrix }} + ref: ${{ needs.release.outputs.tag }} + + build: + needs: [release, setup, base] name: build ${{ matrix.variation }} strategy: fail-fast: false @@ -73,10 +89,9 @@ jobs: registry-repositories: "docker.io/serversideup/php,ghcr.io/serversideup/php" tag-prefix: '' release-type: "latest" - secrets: inherit test: - needs: [release, setup, build] + needs: [release, setup, base, build] if: always() && needs.setup.result == 'success' uses: ./.github/workflows/service_test-images.yml permissions: @@ -96,7 +111,19 @@ jobs: id-token: write with: ref: ${{ needs.release.outputs.tag }} - secrets: inherit + secrets: + DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} + DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + # Scans what was just published: counts in the run summary, fixable findings as code scanning alerts. + scan: + needs: [release, publish] + if: needs.publish.result == 'success' + uses: ./.github/workflows/service_scan-images.yml + permissions: + actions: read + contents: read + security-events: write report: needs: [release, setup, build, test, publish] @@ -106,4 +133,3 @@ jobs: contents: read with: matrix: ${{ needs.setup.outputs.matrix }} - ref: ${{ needs.release.outputs.tag }} diff --git a/.github/workflows/action_publish-images-prs.yml b/.github/workflows/action_publish-images-prs.yml index 35b5f765..daba9c78 100644 --- a/.github/workflows/action_publish-images-prs.yml +++ b/.github/workflows/action_publish-images-prs.yml @@ -38,8 +38,26 @@ jobs: with: ref: ${{ inputs.pr_number && format('refs/pull/{0}/head', inputs.pr_number) || github.ref }} - build: + # Pull requests from forks skip the base phase and build on the official images. + base: needs: [setup] + uses: ./.github/workflows/service_build-php-base.yml + permissions: + contents: read + id-token: write + with: + matrix: ${{ needs.setup.outputs.base-matrix }} + ref: ${{ inputs.pr_number && format('refs/pull/{0}/head', inputs.pr_number) || github.ref }} + publish: >- + ${{ + github.event_name == 'workflow_dispatch' || + github.event.pull_request.head.repo.full_name == github.repository + }} + + build: + needs: [setup, base] + # Forks skip every job in the base phase, so build when it was skipped or succeeded. + if: ${{ !cancelled() && needs.setup.result == 'success' && (needs.base.result == 'success' || needs.base.result == 'skipped') }} name: build ${{ matrix.variation }} strategy: fail-fast: false @@ -57,20 +75,15 @@ jobs: tag-prefix: ${{ inputs.pr_number || github.event.pull_request.number }} release-type: testing # Only save and publish when the code comes from this repository or a maintainer asked for it. - # Pull requests from forks are built but never leave Depot's isolated builders. Dependabot - # runs have no access to the registry secrets, so they are treated the same way. + # Pull requests from forks are built but never leave Depot's isolated builders. publish: >- ${{ github.event_name == 'workflow_dispatch' || - ( - github.event.pull_request.head.repo.full_name == github.repository && - github.actor != 'dependabot[bot]' - ) + github.event.pull_request.head.repo.full_name == github.repository }} - secrets: inherit test: - needs: [setup, build] + needs: [setup, base, build] if: always() && needs.setup.result == 'success' uses: ./.github/workflows/service_test-images.yml permissions: @@ -91,7 +104,9 @@ jobs: with: ref: ${{ inputs.pr_number && format('refs/pull/{0}/head', inputs.pr_number) || github.ref }} authenticate_with_ghcr: false - secrets: inherit + secrets: + DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} + DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} report: needs: [setup, build, test, publish] @@ -101,18 +116,14 @@ jobs: contents: read with: matrix: ${{ needs.setup.outputs.matrix }} - ref: ${{ inputs.pr_number && format('refs/pull/{0}/head', inputs.pr_number) || github.ref }} comment: - needs: [build, test, publish, report] + needs: [base, build, test, publish, report] if: >- always() && ( github.event_name == 'workflow_dispatch' || - ( - github.event.pull_request.head.repo.full_name == github.repository && - github.actor != 'dependabot[bot]' - ) + github.event.pull_request.head.repo.full_name == github.repository ) runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -122,6 +133,7 @@ jobs: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} PR_NUMBER: ${{ inputs.pr_number || github.event.pull_request.number }} + BASE_RESULT: ${{ needs.base.result }} BUILD_RESULT: ${{ needs.build.result }} TEST_RESULT: ${{ needs.test.result }} PUBLISH_RESULT: ${{ needs.publish.result }} @@ -141,6 +153,11 @@ jobs: failure) TEST_CELL="❌ Failed" ;; *) TEST_CELL="⏭️ Skipped" ;; esac + # A failed base skips every build, which leaves the test phase nothing to test. + if [ "$BASE_RESULT" = "failure" ] || [ "$BASE_RESULT" = "cancelled" ]; then + BUILD_CELL="❌ PHP base images failed to compile" + TEST_CELL="⏭️ Skipped" + fi case "$PUBLISH_RESULT" in success) PUBLISH_CELL="✅ Published to serversideup/php-dev" ;; failure) PUBLISH_CELL="❌ Failed" ;; diff --git a/.github/workflows/action_update-dockerhub-readme.yml b/.github/workflows/action_update-dockerhub-readme.yml index 10fca484..3ff986ef 100644 --- a/.github/workflows/action_update-dockerhub-readme.yml +++ b/.github/workflows/action_update-dockerhub-readme.yml @@ -8,18 +8,23 @@ on: paths: - README.md +permissions: {} + jobs: update_container_readme: runs-on: ubuntu-24.04 name: Push README to Docker Hub + permissions: + contents: read steps: - name: git checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: main - name: push README to Dockerhub - uses: peter-evans/dockerhub-description@v5 + uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0 with: username: ${{ secrets.DOCKER_HUB_README_USERNAME }} password: ${{ secrets.DOCKER_HUB_README_PASSWORD }} diff --git a/.github/workflows/scheduled-task_scorecard.yml b/.github/workflows/scheduled-task_scorecard.yml new file mode 100644 index 00000000..7b40d0bc --- /dev/null +++ b/.github/workflows/scheduled-task_scorecard.yml @@ -0,0 +1,46 @@ +name: OpenSSF Scorecard + +# Scores the repository's supply chain practices (pinned dependencies, token permissions, +# signed releases, branch protection, and more) and publishes the result to +# scorecard.dev, which serves the badge in the README. Publishing only works while this +# workflow follows Scorecard's rules: no workflow-level env, defaults, or write +# permissions, and only Scorecard's approved actions in the job. +on: + workflow_dispatch: + branch_protection_rule: + push: + branches: + - main + schedule: + - cron: '41 6 * * 3' + +permissions: {} + +jobs: + analysis: + # Forks inherit the cron schedule. Their weekly rebuilds always fail and email whoever last edited the schedule upstream. + if: github.event_name != 'schedule' || github.repository == 'serversideup/docker-php' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + actions: read + security-events: write + id-token: write + steps: + - name: Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run Scorecard + uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload the results to the Security tab + uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + with: + sarif_file: results.sarif diff --git a/.github/workflows/scheduled-task_update-readme.yml b/.github/workflows/scheduled-task_update-readme.yml index c389c3b2..fc48fa72 100644 --- a/.github/workflows/scheduled-task_update-readme.yml +++ b/.github/workflows/scheduled-task_update-readme.yml @@ -7,6 +7,6 @@ jobs: update-readme: # Forks inherit the cron schedule. Their weekly rebuilds always fail and email whoever last edited the schedule upstream. if: github.event_name != 'schedule' || github.repository == 'serversideup/docker-php' - uses: serversideup/.github/.github/workflows/update-readme.yml@master + uses: serversideup/.github/.github/workflows/update-readme.yml@0cc5de42120438871a6c36a91b6a7dc2a15dbd7f # v1.0.1 permissions: contents: write diff --git a/.github/workflows/scheduled-task_watch-php-releases.yml b/.github/workflows/scheduled-task_watch-php-releases.yml new file mode 100644 index 00000000..26314bf0 --- /dev/null +++ b/.github/workflows/scheduled-task_watch-php-releases.yml @@ -0,0 +1,74 @@ +name: Watch for PHP releases + +# PHP is compiled from php.net, so a new patch can ship as soon as php.net lists it. +# Every hour this resolves the active patches the same way a build does, and starts a +# production run when one of them has no image on Docker Hub yet. Like the weekly +# rebuild, that run rebuilds the latest release and only moves the floating tags. +on: + workflow_dispatch: + schedule: + - cron: '17 * * * *' + +permissions: {} + +jobs: + watch: + # Forks inherit the cron schedule. Their weekly rebuilds always fail and email whoever last edited the schedule upstream. + if: github.event_name != 'schedule' || github.repository == 'serversideup/docker-php' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + actions: write + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PRODUCTION_WORKFLOW: action_publish-images-production.yml + DOCKER_HUB_REPOSITORY: serversideup/php + # A run that started this recently either is still going or already opened a ci-failure issue. + COOLDOWN_HOURS: 6 + steps: + - name: Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Resolve the active PHP patches + run: ./scripts/get-php-versions.sh + + # Each patch is looked up as its cli tag on the first OS it builds for. Anything other + # than "found" or "not found" is skipped, so a Docker Hub outage never starts a run. + - name: Find patches that have no image yet + id: missing + run: | + MISSING=() + while IFS=$'\t' read -r PATCH OS; do + TAG="${PATCH}-cli-${OS}" + STATUS=$(curl -s -o /dev/null -w '%{http_code}' --retry 3 --retry-delay 5 \ + "https://hub.docker.com/v2/namespaces/${DOCKER_HUB_REPOSITORY%/*}/repositories/${DOCKER_HUB_REPOSITORY#*/}/tags/${TAG}") + case "$STATUS" in + 200) echo "$PATCH is published ($TAG)" ;; + 404) echo "$PATCH has no image yet ($TAG)"; MISSING+=("$PATCH") ;; + *) echo "::warning title=Docker Hub lookup failed::$TAG returned HTTP $STATUS, skipping $PATCH this hour" ;; + esac + done < <(yq -o=json scripts/conf/php-versions.yml | jq -r ' + .php_versions[].minor_versions[] + | .base_os[0].name as $os + | .patch_versions[] | select(test("rc") | not) + | [., $os] | @tsv') + echo "patches=${MISSING[*]}" >> "$GITHUB_OUTPUT" + + - name: Start a production run + if: steps.missing.outputs.patches != '' + env: + PATCHES: ${{ steps.missing.outputs.patches }} + run: | + RECENT=$(gh run list --workflow "$PRODUCTION_WORKFLOW" --limit 10 --json status,createdAt,url \ + | jq -r --argjson hours "$COOLDOWN_HOURS" \ + '[.[] | select(.status != "completed" or ((.createdAt | fromdateiso8601) > (now - $hours * 3600)))][0].url // ""') + if [ -n "$RECENT" ]; then + echo "::notice title=Already building::$RECENT started in the last ${COOLDOWN_HOURS} hours, so no new run was started for PHP $PATCHES." + exit 0 + fi + gh workflow run "$PRODUCTION_WORKFLOW" -f reason="PHP ${PATCHES// /, } release" + echo "::notice title=Production run started::PHP $PATCHES has no published image yet." diff --git a/.github/workflows/service_build-images.yml b/.github/workflows/service_build-images.yml index fa382312..4f1cb280 100644 --- a/.github/workflows/service_build-images.yml +++ b/.github/workflows/service_build-images.yml @@ -1,11 +1,12 @@ name: Build Images -# Phase 1 of 3: build every image for one variation. Nothing is pushed to a public -# registry here. When publishing is requested, each image is saved to the Depot -# Registry so the image tests can run against it and, if they pass, -# service_publish-images.yml promotes the exact same image to Docker Hub and GHCR. -# When it is not (pull requests from forks), the amd64 image is loaded onto the -# runner and tested right here instead, so a green build still means a tested image. +# Phase 1 of 3: build every image for one variation on the PHP base images that +# service_build-php-base.yml compiled. Nothing is pushed to a public registry here. +# When publishing is requested, each image is saved to the Depot Registry so the +# image tests can run against it and, if they pass, service_publish-images.yml +# promotes the exact same image to Docker Hub and GHCR. When it is not (pull requests +# from forks), the amd64 image is loaded onto the runner and tested right here +# instead, so a green build still means a tested image. on: workflow_call: inputs: @@ -58,21 +59,24 @@ jobs: steps: - name: Check out code. - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ inputs.ref }} - name: Download PHP Versions file - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: php-versions.yml path: ./artifacts - name: Move PHP Versions file - run: mv ./artifacts/php-versions.yml ${{ inputs.php-versions-file }} + env: + PHP_VERSIONS_FILE: ${{ inputs.php-versions-file }} + run: mv ./artifacts/php-versions.yml "$PHP_VERSIONS_FILE" - name: Set up Depot CLI - uses: depot/setup-action@v1 + uses: depot/setup-action@91bc8495a33ebfc504ffc89e5674379ccf23c29c # v1.7.2 - name: "📦 Assemble the Docker Tags" run: | @@ -91,39 +95,66 @@ jobs: - name: Set REPOSITORY_BUILD_VERSION id: set_version + env: + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + INPUT_REF: ${{ inputs.ref }} + RUN_ID: ${{ github.run_id }} run: | - if [[ "${{ github.ref_type }}" == "tag" || "${{ inputs.ref }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + if [[ "$REF_TYPE" == "tag" || "$INPUT_REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then # Building from a tag (release or scheduled task) echo "🚀 Setting REPOSITORY_BUILD_VERSION to Tag + Run ID" - if [[ "${{ github.ref_type }}" == "tag" ]]; then - VERSION="${{ github.ref_name }}" + if [[ "$REF_TYPE" == "tag" ]]; then + VERSION="$REF_NAME" else - VERSION="${{ inputs.ref }}" + VERSION="$INPUT_REF" fi - echo "REPOSITORY_BUILD_VERSION=${VERSION}-${{ github.run_id }}" >> "$GITHUB_ENV" + echo "REPOSITORY_BUILD_VERSION=${VERSION}-${RUN_ID}" >> "$GITHUB_ENV" else # Regular git build (PR, branch push, etc). Read the SHA from the checkout so # builds dispatched for a PR number report the PR's commit, not the dispatched branch. echo "👨‍🔬 Setting REPOSITORY_BUILD_VERSION to Git SHA + Run ID" SHORT_SHA=$(git rev-parse --short=7 HEAD) - echo "REPOSITORY_BUILD_VERSION=git-${SHORT_SHA}-${{ github.run_id }}" >> "$GITHUB_ENV" + echo "REPOSITORY_BUILD_VERSION=git-${SHORT_SHA}-${RUN_ID}" >> "$GITHUB_ENV" fi - name: Compute NGINX build-arg (only for fpm-nginx) id: compute_nginx if: ${{ matrix.php_variation == 'fpm-nginx' }} + env: + BASE_OS: ${{ matrix.base_os }} + PHP_VERSIONS_FILE: ${{ inputs.php-versions-file }} run: | if command -v yq >/dev/null 2>&1; then - VERSION=$(yq -r '.operating_systems[].versions[] | select(.version == "${{ matrix.base_os }}") | .nginx_version' '${{ inputs.php-versions-file }}' | head -n1) + VERSION=$(yq -r '.operating_systems[].versions[] | select(.version == strenv(BASE_OS)) | .nginx_version' "$PHP_VERSIONS_FILE" | head -n1) else - VERSION=$(awk -v key="${{ matrix.base_os }}" 'BEGIN{found=0} $1=="version:" && $2==key {found=1} found && $1=="nginx_version:" {print $2; exit}' "${{ inputs.php-versions-file }}") + VERSION=$(awk -v key="$BASE_OS" 'BEGIN{found=0} $1=="version:" && $2==key {found=1} found && $1=="nginx_version:" {print $2; exit}' "$PHP_VERSIONS_FILE") fi if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then - echo "Unable to determine NGINX version for OS ${{ matrix.base_os }}" 1>&2 + echo "Unable to determine NGINX version for OS $BASE_OS" 1>&2 exit 1 fi echo "nginx_arg=NGINX_VERSION=$VERSION" >> "$GITHUB_OUTPUT" + # Every pinned dependency version from the base config, plus the PHP base image that + # service_build-php-base.yml compiled for this image. Builds that are not saved (forks) + # have no compiled base and use the Dockerfile's default, the official image. + - name: Collect build args + id: build_args + env: + PUBLISH: ${{ inputs.publish }} + PHP_VERSIONS_FILE: ${{ inputs.php-versions-file }} + PHP_BASE_TAG: ${{ matrix.php_base_tag }} + run: | + { + echo "args<> "$GITHUB_OUTPUT" + echo "count=$COUNT" >> "$GITHUB_OUTPUT" + echo "$COUNT images to scan" + + scan: + needs: plan + if: needs.plan.outputs.count > 0 + name: scan ${{ matrix.variation }} + runs-on: ubuntu-24.04 + timeout-minutes: 60 + permissions: {} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.plan.outputs.matrix) }} + steps: + # The database goes in a volume so every image in the group is scanned against the + # same one, downloaded once. + - name: Download the vulnerability database + run: docker run --rm -v grype-db:/db -e GRYPE_DB_CACHE_DIR=/db "anchore/grype:${GRYPE_VERSION}" db update + + # Grype names every image's findings after the same file (php//lib/apk/db/installed), + # which code scanning would merge into one alert across all images. Each finding is + # moved under its image, keyed by PHP minor version so an alert stays the same alert + # when a new PHP patch ships. + - name: Scan every image in the group + env: + IMAGES: ${{ matrix.images }} + VARIATION: ${{ matrix.variation }} + SCAN_ATTEMPTS: 3 + run: | + mkdir -p raw fixable + chmod 777 raw + + scan_image() { + local image="$1" tag="$2" digest + digest=$(docker buildx imagetools inspect "$tag" --format '{{json .Manifest.Digest}}' | jq -r .) || return 1 + docker run --rm -v grype-db:/db -v "$PWD/raw:/out" -e GRYPE_DB_CACHE_DIR=/db -e GRYPE_DB_AUTO_UPDATE=false \ + "anchore/grype:${GRYPE_VERSION}" "registry:${SCAN_REPOSITORY}@${digest}" --platform "$SCAN_PLATFORM" --quiet \ + --output "json=/out/${image}.json" --output "sarif=/out/${image}.sarif" + } + + FAILED=() + { + echo "## ${VARIATION}: vulnerabilities by image" + echo "" + echo "| Image | Critical | High | Medium | Fixable |" + echo "|---|---|---|---|---|" + } >> "$GITHUB_STEP_SUMMARY" + while IFS=$'\t' read -r IMAGE PHP OS TAG; do + echo "::group::${IMAGE}" + attempt=1 + until scan_image "$IMAGE" "$TAG"; do + if [ "$attempt" -ge "$SCAN_ATTEMPTS" ]; then + echo "::error title=Scan failed::${IMAGE} could not be scanned after ${SCAN_ATTEMPTS} attempts" + FAILED+=("$IMAGE") + break + fi + sleep $(( 10 * attempt )) + attempt=$(( attempt + 1 )) + done + if [ -s "raw/${IMAGE}.json" ] && [ -s "raw/${IMAGE}.sarif" ]; then + case "$PHP" in + *-rc*) KEY="${PHP}-${OS}" ;; + *) KEY="${PHP%.*}-${OS}" ;; + esac + # shellcheck disable=SC2016 # the backticks are Markdown + jq -r --arg image "$IMAGE" '[.matches[].vulnerability] as $v + | def count(s): [$v[] | select(.severity == s)] | length; + "| `\($image)` | \(count("Critical")) | \(count("High")) | \(count("Medium")) | \([$v[] | select(.fix.state == "fixed")] | length) |"' \ + "raw/${IMAGE}.json" | tee -a "$GITHUB_STEP_SUMMARY" + jq --slurpfile scan "raw/${IMAGE}.json" --arg key "$KEY" ' + ([$scan[0].matches[] | select(.vulnerability.fix.state == "fixed") | "\(.vulnerability.id)-\(.artifact.name)"] | unique) as $fixable + | .runs[0] + | { + rules: [.tool.driver.rules[] | select(.id as $id | $fixable | index($id))], + results: [.results[] | select(.ruleId as $id | $fixable | index($id)) + | .locations |= map(.physicalLocation.artifactLocation.uri |= ($key + "/" + sub("^[^/]*//"; ""))) + | .partialFingerprints = {primaryLocationLineHash: "\($key):\(.ruleId):1"}] + }' "raw/${IMAGE}.sarif" > "fixable/${IMAGE}.json" + fi + echo "::endgroup::" + done < <(echo "$IMAGES" | jq -r '.[] | [.image, .php, .os, .tag] | @tsv') + + if [ "${#FAILED[@]}" -gt 0 ]; then + echo "::error title=Scan failed::${FAILED[*]} were not scanned" + exit 1 + fi + + # One run for the whole variation keeps the upload within code scanning's limit of 20 runs per file. + TEMPLATE=$(find raw -name '*.sarif' | head -n1) + jq -s --slurpfile template "$TEMPLATE" '. as $images | { + "$schema": $template[0]["$schema"], + version: $template[0].version, + runs: [{ + tool: {driver: ($template[0].runs[0].tool.driver | .rules = ([$images[].rules[]] | unique_by(.id)))}, + results: [$images[].results[]] + }] + }' fixable/*.json > "${VARIATION}.sarif" + echo "$(jq '.runs[0].results | length' "${VARIATION}.sarif") fixable findings across $(find fixable -name '*.json' | wc -l) images" + + - name: Upload the findings + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: sarif-${{ matrix.variation }} + path: ${{ matrix.variation }}.sarif + retention-days: 7 + + alerts: + needs: [plan, scan] + if: needs.scan.result == 'success' + name: alerts ${{ matrix.variation }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + security-events: write + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.plan.outputs.matrix) }} + steps: + - name: Download the findings + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: sarif-${{ matrix.variation }} + + - name: Update the code scanning alerts + uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + with: + sarif_file: ${{ matrix.variation }}.sarif + category: grype/${{ matrix.variation }} + ref: refs/heads/main + sha: ${{ needs.plan.outputs.main-sha }} diff --git a/.github/workflows/service_setup-matrix.yml b/.github/workflows/service_setup-matrix.yml index 4f301bc1..cdaced6f 100644 --- a/.github/workflows/service_setup-matrix.yml +++ b/.github/workflows/service_setup-matrix.yml @@ -18,6 +18,9 @@ on: variations: description: 'JSON array of variation names, e.g. ["cli","fpm"]' value: ${{ jobs.matrix.outputs.variations }} + base-matrix: + description: 'One entry per PHP base image the variations build on, e.g. {"include": [{"php_base_tag": "php-base-8.4.27-fpm-trixie", ...}]}' + value: ${{ jobs.matrix.outputs.base-matrix }} jobs: matrix: @@ -28,10 +31,12 @@ jobs: outputs: matrix: ${{ steps.matrix.outputs.matrix }} variations: ${{ steps.matrix.outputs.variations }} + base-matrix: ${{ steps.matrix.outputs.base-matrix }} steps: - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ inputs.ref }} - name: Prepare PHP versions for the matrix. 😎 @@ -42,17 +47,21 @@ jobs: SKIP_DOWNLOAD: false - name: Ensure our PHP Versions file exists. + env: + PHP_VERSIONS_FILE: ${{ inputs.php-versions-file }} run: | - if [ ! -f "${{ inputs.php-versions-file }}" ]; then - echo "::error::PHP Versions file does not exist at ${{ inputs.php-versions-file }}" + if [ ! -f "$PHP_VERSIONS_FILE" ]; then + echo "::error::PHP Versions file does not exist at $PHP_VERSIONS_FILE" exit 1 fi - cat "${{ inputs.php-versions-file }}" + cat "$PHP_VERSIONS_FILE" - name: Assemble PHP versions into the matrix. 😎 id: matrix + env: + PHP_VERSIONS_FILE: ${{ inputs.php-versions-file }} run: | - FULL_MATRIX=$(bash ./scripts/generate-matrix.sh '${{ inputs.php-versions-file }}') + FULL_MATRIX=$(bash ./scripts/generate-matrix.sh "$PHP_VERSIONS_FILE") # Group the matrix by variation so each variation becomes its own job group in the GitHub UI. MATRIX_BY_VARIATION=$(echo "$FULL_MATRIX" | jq -c ' @@ -62,23 +71,27 @@ jobs: | from_entries ') VARIATIONS=$(echo "$MATRIX_BY_VARIATION" | jq -c 'keys') + BASE_MATRIX=$(echo "$FULL_MATRIX" | jq -c '{ include: (.include | unique_by(.php_base_tag) | map({ patch_version, base_os, php_base, php_base_tag, docker_library_ref })) }') - echo "matrix=${MATRIX_BY_VARIATION}" >> "$GITHUB_OUTPUT" - echo "variations=${VARIATIONS}" >> "$GITHUB_OUTPUT" + { + echo "matrix=${MATRIX_BY_VARIATION}" + echo "variations=${VARIATIONS}" + echo "base-matrix=${BASE_MATRIX}" + } >> "$GITHUB_OUTPUT" echo "$MATRIX_BY_VARIATION" | jq '.' { - echo "## Build matrix: $(echo "$FULL_MATRIX" | jq '.include | length') images across $(echo "$VARIATIONS" | jq 'length') variations" + echo "## Build matrix: $(echo "$FULL_MATRIX" | jq '.include | length') images across $(echo "$VARIATIONS" | jq 'length') variations, on $(echo "$BASE_MATRIX" | jq '.include | length') PHP base images" echo "" echo "| PHP | Resolved patch version | Base OS |" echo "|---|---|---|" - yq -o=json '${{ inputs.php-versions-file }}' | jq -r ' + yq -o=json "$PHP_VERSIONS_FILE" | jq -r ' .php_versions[].minor_versions[] | "| \(.minor) | \(.patch_versions | join(", ")) | \(.base_os | map(.name) | join(", ")) |"' } >> "$GITHUB_STEP_SUMMARY" - name: Upload the php-versions.yml file - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: php-versions.yml path: ${{ inputs.php-versions-file }} diff --git a/.github/workflows/service_test-images.yml b/.github/workflows/service_test-images.yml index cd00ea31..bd2cd903 100644 --- a/.github/workflows/service_test-images.yml +++ b/.github/workflows/service_test-images.yml @@ -26,7 +26,7 @@ jobs: count: ${{ steps.list.outputs.count }} steps: - name: Download image details from every build job - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: image-details-* path: image-details @@ -65,12 +65,13 @@ jobs: matrix: ${{ fromJson(needs.list.outputs.matrix) }} steps: - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ inputs.ref }} - name: Set up Depot CLI - uses: depot/setup-action@v1 + uses: depot/setup-action@91bc8495a33ebfc504ffc89e5674379ccf23c29c # v1.7.2 with: oidc: true @@ -83,10 +84,12 @@ jobs: env: IMAGES: ${{ matrix.images }} PHP: ${{ matrix.php }} + OS: ${{ matrix.os }} + ARCH: ${{ matrix.arch }} run: | FAILED=() while IFS=$'\t' read -r VARIATION SAVED; do - echo "::group::${VARIATION} ${PHP}-${{ matrix.os }} ${{ matrix.arch }}" + echo "::group::${VARIATION} ${PHP}-${OS} ${ARCH}" if ! { docker pull --quiet "$SAVED" && bash scripts/test-image.sh "$SAVED" "$PHP"; }; then FAILED+=("$VARIATION") fi @@ -94,6 +97,6 @@ jobs: done < <(echo "$IMAGES" | jq -r '.[] | [.variation, .saved] | @tsv') if [ "${#FAILED[@]}" -gt 0 ]; then - echo "::error title=Image tests failed::${FAILED[*]} failed for ${PHP}-${{ matrix.os }} on ${{ matrix.arch }}" + echo "::error title=Image tests failed::${FAILED[*]} failed for ${PHP}-${OS} on ${ARCH}" exit 1 fi diff --git a/.github/workflows/trigger_auto-retry-failed-builds.yml b/.github/workflows/trigger_auto-retry-failed-builds.yml index 330a7b8c..e82ed8cf 100644 --- a/.github/workflows/trigger_auto-retry-failed-builds.yml +++ b/.github/workflows/trigger_auto-retry-failed-builds.yml @@ -5,8 +5,14 @@ name: Auto-retry failed image builds # the failure looks transient. When a run is still red after that, or was never worth # retrying, it opens an issue for a human. Pull request runs skip the issue because the # PR comment already carries the result. +# +# Runs are found by workflow file, because `workflow_run.name` holds the run's title +# (`run-name:`), not the workflow's name. +# +# workflow_run is safe here: this never checks out or runs code from the run it reacts +# to, and only reads that run's metadata and logs. on: - workflow_run: + workflow_run: # zizmor: ignore[dangerous-triggers] workflows: - "Docker Publish (Production Images)" - "Docker Publish (Beta Images)" @@ -17,7 +23,8 @@ permissions: {} jobs: triage: - if: github.event.workflow_run.conclusion == 'failure' + # A Depot runner that never connects leaves its job failed or cancelled, which can end the whole run as cancelled. + if: contains(fromJSON('["failure", "cancelled"]'), github.event.workflow_run.conclusion) runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -30,7 +37,7 @@ jobs: RUN_URL: ${{ github.event.workflow_run.html_url }} RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} RUN_TITLE: ${{ github.event.workflow_run.display_title }} - WORKFLOW_NAME: ${{ github.event.workflow_run.name }} + RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }} WORKFLOW_PATH: ${{ github.event.workflow_run.path }} TRIGGER: ${{ github.event.workflow_run.event }} HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} @@ -48,15 +55,27 @@ jobs: sleep 120 fi - LATEST=$(gh run list --workflow "$WORKFLOW_NAME" --branch "$HEAD_BRANCH" --limit 1 --json databaseId --jq '.[0].databaseId') + LATEST=$(gh run list --workflow "$(basename "$WORKFLOW_PATH")" --branch "$HEAD_BRANCH" --limit 1 --json databaseId --jq '.[0].databaseId') if [ "$LATEST" != "$RUN_ID" ]; then echo "::notice title=Skipped::A newer run already exists for $HEAD_BRANCH, so this one is stale." echo "action=none" >> "$GITHUB_OUTPUT" exit 0 fi - FAILED=$(gh run view "$RUN_ID" --json jobs --jq '[.jobs[] | select(.conclusion == "failure")] | length') - echo "Failed jobs in $RUN_URL (attempt $RUN_ATTEMPT): $FAILED" + JOBS=$(gh run view "$RUN_ID" --json jobs --jq '.jobs') + FAILED=$(echo "$JOBS" | jq '[.[] | select(.conclusion == "failure")] | length') + CANCELLED=$(echo "$JOBS" | jq '[.[] | select(.conclusion == "cancelled")] | length') + + # A run someone cancelled by hand has no failed jobs. A runner that never connects + # fails one job and cancels others, so that case is retried like any other failure. + if [ "$RUN_CONCLUSION" = "cancelled" ] && [ "$FAILED" = "0" ]; then + echo "::notice title=Skipped::The run was cancelled and no job failed, so it was most likely cancelled on purpose." + echo "action=none" >> "$GITHUB_OUTPUT" + exit 0 + fi + + FAILED=$((FAILED + CANCELLED)) + echo "Failed or cancelled jobs in $RUN_URL (attempt $RUN_ATTEMPT): $FAILED" if [ "$RUN_ATTEMPT" = "1" ] && [ "$FAILED" -le "$MAX_FAILED_JOBS" ]; then echo "action=retry" >> "$GITHUB_OUTPUT" @@ -76,7 +95,7 @@ jobs: echo "::notice title=Retry started::Re-running failed jobs for $RUN_URL. If this attempt also fails, an issue is opened." - name: Open an issue for a human - if: steps.check.outputs.action == 'issue' && !contains(env.WORKFLOW_NAME, 'PR Images') + if: steps.check.outputs.action == 'issue' && !endsWith(env.WORKFLOW_PATH, 'action_publish-images-prs.yml') env: REASON: ${{ steps.check.outputs.reason }} MAX_DETAILED_JOBS: 8 @@ -84,7 +103,7 @@ jobs: run: | JOBS=$(gh run view "$RUN_ID" --json jobs --jq '.jobs') TOTAL=$(echo "$JOBS" | jq 'length') - FAILED_JOBS=$(echo "$JOBS" | jq -c '[.[] | select(.conclusion == "failure") + FAILED_JOBS=$(echo "$JOBS" | jq -c '[.[] | select(.conclusion == "failure" or .conclusion == "cancelled") | { id: .databaseId, name, url, step: ((.steps[]? | select(.conclusion == "failure") | .name) // "-") }]') FAILED=$(echo "$FAILED_JOBS" | jq 'length') WORKFLOW_URL="https://github.com/${GH_REPO}/actions/workflows/$(basename "$WORKFLOW_PATH")" @@ -107,7 +126,7 @@ jobs: { echo "**[Re-run failed jobs]($RUN_URL)** · [Run summary]($RUN_URL#summary) · [Workflow history]($WORKFLOW_URL) · attempt $RUN_ATTEMPT" echo "" - echo "$FAILED of $TOTAL jobs failed on \`$HEAD_BRANCH\` at [\`$SHORT_SHA\`](https://github.com/${GH_REPO}/commit/${HEAD_SHA}), triggered by \`$TRIGGER\`. $REASON" + echo "$FAILED of $TOTAL jobs failed or were cancelled on \`$HEAD_BRANCH\` at [\`$SHORT_SHA\`](https://github.com/${GH_REPO}/commit/${HEAD_SHA}), triggered by \`$TRIGGER\`. $REASON" echo "" echo "### Failed jobs" echo "" diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 00000000..d9628331 --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,6 @@ +# zizmor configuration for .github/workflows +# Each disabled rule is a deliberate project decision, not an oversight. +rules: + # Its fix, `uses: $/...`, is rejected by actionlint 1.7.12, which also lints these workflows. + self-repository: + disable: true diff --git a/.gitignore b/.gitignore index 310c9d1b..8f973cbe 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ node_modules php-versions.yml *.tmp /docs/_OLD_ -.playwright-mcp \ No newline at end of file +.playwright-mcp +.php-base/ diff --git a/.oss-scanner/threat_model.md b/.oss-scanner/threat_model.md index 2d1b8143..859b1740 100644 --- a/.oss-scanner/threat_model.md +++ b/.oss-scanner/threat_model.md @@ -1,12 +1,16 @@ # Threat model ## What this project does and where untrusted input enters -serversideup/php publishes PHP Docker images (`cli`, `fpm`, `fpm-nginx`, `fpm-apache`, `frankenphp`) on Debian and Alpine. We build on the official `php` images and add entrypoint scripts, S6 Overlay services, and web server configs. All of it is configured through environment variables. The images run as `www-data` and listen on 8080 and 8443. See `SECURITY.md` for our full scope. +serversideup/php publishes PHP Docker images (`cli`, `fpm`, `fpm-nginx`, `fpm-apache`, `frankenphp`) on Debian and Alpine. We compile PHP with the official docker-library/php Dockerfiles at a pinned commit and add entrypoint scripts, S6 Overlay services, and web server configs. All of it is configured through environment variables. The images run as `www-data` and listen on 8080 and 8443. See `SECURITY.md` for our full scope. Untrusted input: - **HTTP requests** to the NGINX, Apache, and Caddy (FrankenPHP) configs we ship. Assume any client on the network can send them. This includes headers that reach PHP or our real-IP handling (`TRUSTED_PROXY`), requests for `/healthcheck`, PHP-FPM status paths, `/storage/*.php`, dotfiles such as `.env` and `.git`, path info after `index.php`, and plain-HTTP requests when `SSL_MODE` is `mixed` or `full`. - **Pull requests from forks** against `.github/workflows/`. A fork can control the PR title, branch name, and file contents. It must never reach repository secrets, the Depot OIDC trust, or any publish to Docker Hub or GHCR. -- **Downloads during image builds** (s6-overlay, NGINX signing keys, PHP extension installer) through `docker-php-serversideup-download`. Tampering in transit or at the source must be caught by checksums or signatures. +- **Workflows that start without a pull request.** None of these run fork code: + - `scheduled-task_watch-php-releases.yml` runs hourly on `schedule` and on `workflow_dispatch`. It reads php.net's release feed and Docker Hub's tag API, and when a patch has no image it starts the production workflow with `actions: write`. The patch version from php.net reaches the run title through `gh workflow run -f reason=...`. + - `service_scan-images.yml` runs on `workflow_call` after every production publish and on `workflow_dispatch`. Grype, its vulnerability database, and the images it scans are third-party input. The `scan` job has no token and no registry credentials, and runs Grype in its own container. The `alerts` job holds only `security-events: write`, never runs Grype, and uploads the SARIF the scan jobs produce. Text from that SARIF (CVE IDs, package names, versions) ends up in code scanning alerts. + - `scheduled-task_scorecard.yml` runs on `push` to `main`, `schedule`, `branch_protection_rule`, and `workflow_dispatch`. +- **Downloads during image builds** (the PHP tarball from php.net, the docker-library/php Dockerfiles, phpredis and liblzf source, s6-overlay, NGINX signing keys, PHP extension installer) through `docker-php-serversideup-download`. Tampering in transit or at the source must be caught by checksums or signatures. The PHP tarball is checked against php.net's SHA256 and its GPG signature, docker-library/php and liblzf are pinned to commits, and the other downloads are pinned to release tags in `scripts/conf/php-versions-base-config.yml`. Trusted (operators control these): - Environment variables @@ -23,6 +27,7 @@ Most: - Web server configs in `src/variations/*/etc/` and PHP-FPM pools in `src/php-fpm.d/` - The unprivileged model. A path from `www-data` to root, or a default that runs something as root, matters. - `.github/workflows/` and `scripts/` used by CI (what gets built, tested, and published) +- The signing identities. cosign signs images keylessly from `.github/workflows/service_publish-images.yml`, at `refs/heads/main` or a `v*` tag, with GitHub's OIDC issuer. Users trust anything signed by that identity, so a way to run other code inside that workflow matters. Least, or out of scope: - The `docs/` site @@ -47,8 +52,8 @@ kill %1 # stops it, like docke `KEY=VALUE` arguments override the image's environment, like `docker run -e`. Every variation listens on 8080 and 8443, so run one at a time or change the port variables. Scripts in `src/` must run under both Debian `dash` and Alpine BusyBox `sh`, so check behavior on `fpm-nginx-alpine` as well. `scripts/tests/run.sh` tests the CI helper scripts. ## How you rate severity -- **Critical:** remote, unauthenticated code execution or arbitrary file read (for example, `.env` or application source) through a config we ship with default settings. Also a fork pull request that can publish an image or read a secret. -- **High:** escalation from `www-data` to root. A default that exposes secrets, the PHP-FPM status page, or FastCGI to the network. A fork pull request that can run code with write access to the repository or with Depot or registry credentials. A downloaded artifact that is used without verification. +- **Critical:** remote, unauthenticated code execution or arbitrary file read (for example, `.env` or application source) through a config we ship with default settings. Also a fork pull request that can publish an image, sign as one of our workflows, or read a secret. +- **High:** escalation from `www-data` to root. A default that exposes secrets, the PHP-FPM status page, or FastCGI to the network. A fork pull request that can run code with write access to the repository or with Depot or registry credentials. Scanner output that can run code in the `alerts` job or write anything beyond code scanning alerts. A downloaded artifact that is used without verification. - **Medium:** client IP spoofing past `TRUSTED_PROXY`, so that IP-based controls are bypassed. Plain HTTP served when the docs say SSL is enforced. Denial of service from a single unauthenticated request. - **Low:** version banners and other information leaks that give no access. Hardening gaps with no demonstrated impact. @@ -58,4 +63,5 @@ When a finding only applies with a non-default environment variable, rate it one - `COMPOSER_ALLOW_SUPERUSER=1` is intentional for builds that run as root. - `PHP_DISPLAY_ERRORS` and similar settings that operators can turn on for development are not findings. - Images run as `www-data` and can be switched to root with `USER root`. That design is documented and intentional. -- Packages that are not pinned, and floating base image tags, are intentional. Weekly rebuilds pick up security fixes. +- Packages that are not pinned, and floating base image tags, are intentional. Weekly rebuilds pick up security fixes, and the release watcher picks up new PHP patches within the hour. +- Public scan results that list CVEs in our images are intentional. They are already disclosed upstream CVEs that anyone can find by scanning a public image. diff --git a/AGENTS.md b/AGENTS.md index b691fe74..0095cd5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,8 @@ src/ utilities-webservers/ # Shared web server entrypoint utilities (SSL, etc.) scripts/ # Build tooling (Bash, not POSIX) dev.sh # Local image builds - conf/ # PHP version matrix and base config (YAML) + conf/ # PHP version matrix, base config, and every pinned dependency version (YAML) + prepare-php-base.sh # Prepares docker-library/php's Dockerfile to compile a PHP patch generate-matrix.sh # Generates CI build matrix from YAML config assemble-docker-tags.sh docs/ # Nuxt 4 documentation site (see docs/AGENTS.md for docs-specific guidelines) @@ -56,6 +57,8 @@ Each variation Dockerfile uses multi-stage builds: ### Build args used across Dockerfiles - `PHP_VERSION`, `BASE_OS_VERSION`, `PHP_VARIATION` -- set by CI matrix or `scripts/dev.sh` +- `BASE_IMAGE` -- the compiled PHP base from the Depot Registry in CI. Defaults to the official `php:` image, which local builds and fork PRs use. +- Every key under `dependencies` in `scripts/conf/php-versions-base-config.yml` (`S6_VERSION`, `PHPREDIS_VERSION`, `FRANKENPHP_VERSION`, ...) -- passed as a build arg of the same name by CI and `scripts/dev.sh`. Dockerfiles declare them with no default and helper scripts fail when one is missing, so the config is the only place a version lives. To add a dependency, add a line there with a `# renovate:` comment and an `ARG` in each stage that uses it. - `NGINX_VERSION` -- resolved per-OS from `scripts/conf/php-versions-base-config.yml` - `REPOSITORY_BUILD_VERSION` -- image version label @@ -84,21 +87,28 @@ scripts/dev.sh --variation frankenphp --version 8.5 --os bookworm --push ## PHP Version Pipeline PHP versions are NOT hardcoded. The pipeline works like this: -1. `scripts/get-php-versions.sh` fetches the latest active PHP releases from `https://www.php.net/releases/active.php` -2. It validates each version actually exists on DockerHub (with automatic fallback to previous patch if not yet published) -3. The fetched versions are merged with the base config (`scripts/conf/php-versions-base-config.yml`) which defines OS bases, variations, and NGINX versions -4. The merged result is written to `scripts/conf/php-versions.yml` -- this is the source of truth for CI builds -5. `scripts/generate-matrix.sh` reads the final YAML and produces the GitHub Actions matrix JSON +1. `scripts/get-php-versions.sh` fetches the latest active PHP releases from `https://www.php.net/releases/active.php`. There is no Docker Hub check: we compile PHP ourselves, so a release builds as soon as php.net lists it. +2. The fetched versions are merged with the base config (`scripts/conf/php-versions-base-config.yml`) which defines OS bases, variations, dependency versions, and NGINX versions. A minor's `patch_versions` in the base config replaces php.net's, which pins or holds back a release. +3. The merged result is written to `scripts/conf/php-versions.yml` -- this is the source of truth for CI builds +4. `scripts/generate-matrix.sh` reads the final YAML and produces the GitHub Actions matrix JSON, including each image's `php_base_tag` and `docker_library_ref` + +### How PHP is compiled +Each variation's `php_base` (`cli`, `fpm`, or `zts`) names the docker-library/php variant it builds on; a minor's `php_base_overrides` swaps it (8.6 has no `zts` variant, so FrankenPHP uses `cli`). A `-rc` version such as `8.6-rc` builds the newest release candidate from php.net's release-candidates feed and docker-library's `8.6-rc` directory. `scripts/prepare-php-base.sh` downloads docker-library/php at `DOCKER_LIBRARY_PHP_REF` (or the minor's own `docker_library_ref`, as 8.1 has), sets the PHP version, URL, and php.net SHA256, removes `pecl update-channels`, and sets `PHP_BUILD_PROVIDER` to Server Side Up (shown as "Built by" in `php -v`). It checks the result, so a layout change upstream fails loudly. Keep every other line of upstream's Dockerfile untouched: matching the official images' structure is the point. The `redis` extension is built from its GitHub source (with the `liblzf` submodule downloaded separately) so no default build step depends on PECL. When modifying the version pipeline, the base config (`php-versions-base-config.yml`) is the file you edit. Never edit `php-versions.yml` directly -- it's generated. ## CI/CD - Builds run via GitHub Actions using **Depot** (`depot/build-push-action`) for multi-arch (`linux/amd64` + `linux/arm64/v8`). Depot builds both architectures natively on its own builders; the GitHub runner only orchestrates, so runner size and architecture do not affect build speed. -- Publishing is three phases, each a reusable workflow the callers (`action_publish-images-*.yml`) chain: `service_setup-matrix.yml` generates the matrix once; `service_build-images.yml` builds one variation and saves every image to the Depot Registry (nothing public yet), recording the Depot build ID and digest; `service_test-images.yml` pulls the saved images on native amd64 and arm64 Depot runners (Depot sponsors the project, so they are free here and have no concurrency cap), one job per PHP version and base OS so the variations that share base layers share one pull, and runs `scripts/test-image.sh` against each; `service_publish-images.yml` promotes the tested builds with `depot push`, which copies the saved image to every Docker Hub and GHCR tag server-side (no image bytes touch the runner), one job per variation, and fails if the published digest is not the tested one. GHCR allows 2,000 requests a minute per token and `depot push` does not retry a 429, so the publish job pushes only two images at a time and retries a failed push with a growing pause; raising `MAX_PARALLEL` gets every variation's job rate limited at once. `service_report.yml` renders the run table last. Jobs render as `build cli / 8.4.25-bookworm`, `test / 8.4.25-bookworm arm64`, `publish / cli`. +- Publishing is three phases, each a reusable workflow the callers (`action_publish-images-*.yml`) chain: `service_setup-matrix.yml` generates the matrix once; `service_build-php-base.yml` compiles each PHP base (one job per PHP version, base OS, and `php_base`) and saves it to the Depot Registry as `-php-base---`, always with `no-cache` so OS security fixes (a patched libbrotli or OpenSSL) land on the next run instead of when Debian or Alpine publish a new base image; `service_build-images.yml` builds one variation and saves every image to the Depot Registry (nothing public yet), recording the Depot build ID and digest; `service_test-images.yml` pulls the saved images on native amd64 and arm64 Depot runners (Depot sponsors the project, so they are free here and have no concurrency cap), one job per PHP version and base OS so the variations that share base layers share one pull, and runs `scripts/test-image.sh` against each; `service_publish-images.yml` promotes the tested builds with `depot push`, which copies the saved image to every Docker Hub and GHCR tag server-side (no image bytes touch the runner), one job per variation, and fails if the published digest is not the tested one. GHCR allows 2,000 requests a minute per token and `depot push` does not retry a 429, so the publish job pushes only two images at a time and retries a failed push with a growing pause; raising `MAX_PARALLEL` gets every variation's job rate limited at once. `service_report.yml` renders the run table last. Jobs render as `build cli / 8.4.25-bookworm`, `test / 8.4.25-bookworm arm64`, `publish / cli`. - A broken image can never reach a public tag: what users pull is byte-for-byte what the tests ran. Publishing is all or nothing: if any image fails to build or fails its test, no image from that run is published, including on the weekly rebuilds. Saved images live in the Depot Registry until the project's retention setting removes them. +- Build jobs that save images attach an SPDX SBOM and SLSA provenance (`mode=max`) to the image index. After the digest check, `service_publish-images.yml` signs each registry's copy of the index (one signature per registry, covering every tag) with cosign keyless signing through the workflow's OIDC token, so there is no key to store. Users verify against the identity `.github/workflows/service_publish-images.yml@refs/heads/main` or `@refs/tags/v*`; the commands are in `docs/content/docs/5.guide/7.verifying-images.md`. Images published before this have provenance only. +- `service_scan-images.yml` runs after every production publish (and by hand for the latest one). Grype scans the `linux/amd64` image of every published tag, pulled anonymously from GHCR. The scan job has no token and no registry credentials, and Grype runs in its own container, so a compromised scanner has nothing to steal or publish. Every image's counts go to the run summary, which is public. Findings with a fix become code scanning alerts: the `alerts` job (the only one with `security-events: write`, and it never runs Grype) uploads one SARIF run per variation, category `grype/`, always against `main` so release and weekly scans share one alert list. Code scanning allows 20 runs per SARIF file, hence one merged run per variation. Grype gives every image the same file path, which would merge alerts across images, so each finding's path and fingerprint are rewritten under `-`, which also keeps an alert stable across PHP patches. - The build matrix is generated from the PHP version pipeline described above. +- `scheduled-task_watch-php-releases.yml` runs hourly, resolves the active patches with `scripts/get-php-versions.sh`, and starts a production run when a patch's `cli` tag on its first OS is missing from Docker Hub, so new PHP patches publish within about an hour of php.net listing them. A Docker Hub lookup that is neither found nor not found is skipped, and no run starts if a production run started in the last 6 hours. Like the weekly rebuild (Tuesday 08:00 UTC, which delivers OS package updates), it only moves floating tags; `-vX.Y.Z` tags come only from the GitHub release. +- Dependencies are monitored by Renovate (`renovate.json`), which replaced Dependabot. It reads the `# renovate:` comments in the base config and `service_lint.yml`, the `nginx_version` lines (from the nginx.org package listings), docker-library/php `master` (weekly, merged automatically once every check passes; every other pin waits for a manual merge), GitHub Actions, and one docs npm package. Every action is pinned to a full commit SHA with its version in a comment (`helpers:pinGitHubActionDigests`), and releases from GitHub and Docker datasources wait 3 days (`minimumReleaseAge`) before Renovate proposes them, so a hijacked or broken release is usually caught first. Merged bumps reach the published images with the next release; PHP patches need no release because they are resolved from php.net at run time. - Image tags follow the pattern: `serversideup/php:{version}-{variation}` (Debian default) or `serversideup/php:{version}-{variation}-{os}` (Alpine/specific OS). +- Fork PRs skip the base phase and build on the official `php:` images, so they can fail in the hours after a PHP release, and they never exercise changes to how PHP is compiled. - Depot authentication: the project ID lives in `depot.json` (not a secret). Same-repo runs authenticate through a Depot OIDC trust relationship (`id-token: write`). Pull requests from forks have no OIDC token, so Depot falls back to its open-source pull request flow (https://depot.dev/blog/github-actions-oss-fork-builds): the full matrix builds on ephemeral builders without the project cache, nothing is saved, and each build job loads its amd64 image onto the runner and runs `scripts/test-image.sh` there instead, so the build check still means a tested image. A maintainer publishes a fork's images to `serversideup/php-dev`, and gets the arm64 tests, by running the "Docker Publish (PR Images)" workflow manually with the PR number. - The CI helper script `scripts/build-summary.sh` is checked by `scripts/tests/run.sh`, a single file of plain assertions over sample images. Run it after changing the script and add an assertion when you add behavior. Keep jq programs explicit with parentheses: `a + b as $x | ...` parses differently across jq versions, and runners ship an older jq than most laptops. - The project is enrolled in Anthropic's OSS Scanner (`projects/serversideup-php` in anthropics/oss-scanner), which builds `.oss-scanner/Dockerfile` from `main` and audits it offline. That Dockerfile copies `src/` over each published web server image with the same `COPY` lines as `src/variations/*/Dockerfile`, and `.oss-scanner/run-variation` starts each one the way `docker run` would. Nothing fails when these drift, so update them in the same change as: @@ -108,18 +118,20 @@ When modifying the version pipeline, the base config (`php-versions-base-config. - a new way for untrusted input to reach the image (a web server location, a healthcheck, a workflow trigger), a change to `SECURITY.md` scope, or a new intentional default the scanner should not report: `.oss-scanner/threat_model.md` After changing any of these, build it with `docker build -f .oss-scanner/Dockerfile -t oss-scanner .`, then confirm the affected variations start with no network: `docker run --rm -it --privileged --network none --user root oss-scanner bash`, then `.oss-scanner/run-variation &` and `curl localhost:8080/healthcheck`. -- Every PR run starts with `service_lint.yml` (script tests, actionlint for workflows, hadolint for `src/variations/*/Dockerfile` and `.oss-scanner/Dockerfile` with project decisions recorded in `.hadolint.yaml`, ShellCheck at warning severity for `scripts/*.sh`, `src/common/usr/local/bin/*`, `src/s6/usr/local/bin/*`, `.oss-scanner/run-variation`, and `entrypoint.d/*.sh`) before any image builds. New linters go there. The local commands are in the contributing docs. -- `scripts/test-image.sh` checks that an image starts, runs as an unprivileged user, has no world-writable files or directories (outside sticky ones like `/tmp`), reports the expected PHP version, loads the default extensions, reaches a healthy HEALTHCHECK, and (for images with a web server) serves a mounted `index.php` through NGINX, Apache, or Caddy. Run it locally against any image before opening a PR that touches startup behavior. Keep the checks generic across variations: it detects the web server from the image's `*_HTTP_PORT` environment variables rather than the image name. +- Every PR run starts with `service_lint.yml` (script tests, actionlint and zizmor for workflows with zizmor's project decisions recorded in `.github/zizmor.yml`, hadolint for `src/variations/*/Dockerfile` and `.oss-scanner/Dockerfile` with project decisions recorded in `.hadolint.yaml`, ShellCheck at warning severity for `scripts/*.sh`, `scripts/tests/image/*.sh`, `src/common/usr/local/bin/*`, `src/s6/usr/local/bin/*`, `.oss-scanner/run-variation`, and `entrypoint.d/*.sh`) before any image builds. New linters go there. The local commands are in the contributing docs. +- `scripts/test-image.sh [php-version]` runs the image tests in `scripts/tests/image/`: one file per feature area (`php.sh`, `fpm.sh`, `security.sh`, `entrypoint.sh`, `laravel-automations.sh`, `permissions.sh`, `lifecycle.sh`, `web-server.sh`, `ssl.sh`, `trusted-proxy.sh`, `frankenphp.sh`, `octane.sh`), each `test_*` function one test, shared helpers and image facts in `lib.sh`, mounted files in `fixtures/` (`laravel/artisan` records commands instead of running Laravel). Every test runs even after one fails. Add a test to the file for its area, or a new file for a new area; never add checks to `scripts/test-image.sh` itself. Keep tests generic across variations: a file or test declares what it needs with `requires` (`healthcheck`, `web_server`, `https`, `frankenphp`, `fpm`), detected from the image's environment variables rather than its name. Run it locally against any image before opening a PR that touches startup behavior; `TEST_ONLY=` runs one area. - Every build job records its image details (tags, Depot Registry reference, build ID, digest) as an `image-details-*` artifact; the publish job adds a `.published.json` record with the compressed size per architecture. `service_report.yml` merges them with `scripts/build-summary.sh` (the published record wins) into one table on the run summary and in the PR comment. -- `fail-fast` is off, so one failed image never cancels the others. `trigger_auto-retry-failed-builds.yml` re-runs the failed jobs of a production, beta, or PR run once when only a few jobs failed and no newer run exists. If that also fails, or too many jobs failed to look transient, it opens a `ci-failure` issue assigned to the maintainer with the failed jobs, their first error annotation, the log tail, and a link to re-run. Only production and beta runs open issues; PR runs have the PR comment. GitHub's own failure emails do not cover this case reliably: scheduled runs notify whoever last edited the cron line, and re-run attempts are triggered by the Actions bot. +- `scheduled-task_scorecard.yml` runs OpenSSF Scorecard on pushes to `main`, weekly, and on branch protection changes, and publishes the score behind the README badge. Publishing only works while the workflow follows Scorecard's rules: no workflow-level `env`, `defaults`, or write permissions, and only Scorecard's approved actions in the job. +- `fail-fast` is off, so one failed image never cancels the others. `trigger_auto-retry-failed-builds.yml` re-runs the failed jobs of a production, beta, or PR run once when only a few jobs failed and no newer run exists. It also handles runs that end `cancelled` because a Depot runner never connected, but skips a run cancelled by hand (no failed jobs). It finds runs by workflow file, because `workflow_run.name` is the run's `run-name` title. If that also fails, or too many jobs failed to look transient, it opens a `ci-failure` issue assigned to the maintainer with the failed jobs, their first error annotation, the log tail, and a link to re-run. Only production and beta runs open issues; PR runs have the PR comment. GitHub's own failure emails do not cover this case reliably: scheduled runs notify whoever last edited the cron line, and re-run attempts are triggered by the Actions bot. ## Verification -There is no automated test suite for image logic. To verify changes: +To verify image changes: 1. Build the affected variation locally with `scripts/dev.sh` -2. Run the built image and confirm the change works: `docker run --rm -it serversideup/php:8.4-fpm-nginx-bookworm sh` -3. For entrypoint script changes, test on both Debian and Alpine builds -4. Run `shellcheck` on any modified shell scripts when available +2. Run `bash scripts/test-image.sh ` against it, and add a test in `scripts/tests/image/` for new behavior +3. Run the built image and confirm the change works: `docker run --rm -it serversideup/php:8.4-fpm-nginx-bookworm sh` +4. For entrypoint script changes, test on both Debian and Alpine builds +5. Run `shellcheck` on any modified shell scripts when available ## Key Design Decisions diff --git a/README.md b/README.md index a1594ef3..5638fe49 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@

Build Status + OpenSSF Scorecard License Support us
@@ -97,7 +98,7 @@ serversideup/php:{{version}}-{{variation-name}} > [!NOTE] > All images are available on [**Docker Hub**](https://hub.docker.com/r/serversideup/php/) and [**GitHub Packages**](https://github.com/serversideup/docker-php/pkgs/container/php). -We support **PHP 8.2 through 8.5** with both **Debian** and **Alpine** base images. PHP 8.1 is end of life but still built while its base operating systems receive security updates. See our [security policy](SECURITY.md#component-lifecycle-references) for the rule. +We support **PHP 8.2 through 8.5** with both **Debian** and **Alpine** base images. PHP 8.1 is end of life but still built while its base operating systems receive security updates. See our [security policy](SECURITY.md#component-lifecycle-references) for the rule. PHP 8.6 release candidates are available for testing as `8.6-rc` tags, such as `serversideup/php:8.6-rc-fpm-nginx`. Every rebuild uses the newest release candidate on php.net. [Learn More About Choosing an Image →](https://serversideup.net/open-source/docker-php/docs/getting-started/choosing-an-image) diff --git a/SECURITY.md b/SECURITY.md index 429f3e4b..eb4b0acd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,7 +19,7 @@ Vulnerabilities in what *we* author and ship: - Entrypoint scripts, healthchecks, helper scripts, and S6 service definitions in `src/` - Default configs we ship for PHP-FPM, NGINX, Apache, and FrankenPHP - Insecure defaults: ports, file permissions, the `www-data` user model, SSL defaults -- Build pipeline integrity (GitHub Actions workflows, tag assembly, image publishing) +- Build pipeline integrity (GitHub Actions workflows, tag assembly, image publishing, image signing) - Documentation that materially misleads users about a security-relevant setting ## What is out of scope — report to the upstream project @@ -34,7 +34,13 @@ Vulnerabilities in software we package but do not maintain belong with the upstr | Composer | [composer/composer on GitHub](https://github.com/composer/composer/security) | | Debian / Alpine base packages | The respective distribution security teams | -If an upstream CVE is already publicly disclosed, you don't need to file with us — we pick up upstream patches on our **weekly rebuilds (Tuesday 08:00 UTC)** for floating tags. See [How our releases work](https://serversideup.net/open-source/docker-php/docs/getting-started/upgrade-guide#how-our-releases-work) for the full cadence and which tags receive rebuilds. +If an upstream CVE is already publicly disclosed, you don't need to file with us. Floating tags pick up upstream fixes on their own: a new PHP patch publishes within about an hour of php.net listing it, and operating system packages update on the **weekly rebuild (Tuesday 08:00 UTC)**. See [How our releases work](https://serversideup.net/open-source/docker-php/docs/getting-started/upgrade-guide#how-our-releases-work) for the full cadence and which tags receive rebuilds. + +## Verifying images and public scan results + +Images we publish are signed with cosign and carry an SBOM and build provenance. Images published before signing was added have provenance only. [Verifying images](https://serversideup.net/open-source/docker-php/docs/guide/verifying-images) shows how to check them. + +Each production build ends with a vulnerability scan of every published image. The results are in the run summary of each [production run](https://github.com/serversideup/docker-php/actions/workflows/action_publish-images-production.yml), and [Vulnerability scanning](https://serversideup.net/open-source/docker-php/docs/reference/vulnerability-scanning) explains how to read them. Scanner findings are already disclosed upstream CVEs. Anything that is not yet public, including a problem in what we author, still goes through [private reporting](#reporting-a-vulnerability). ## Component lifecycle references @@ -51,7 +57,7 @@ Our images bundle third-party software, each with its own support window. Before We publish images for end-of-life PHP versions so legacy applications have a path into containers, but only while two things remain true: -1. The official `php` image for that version still exists on Docker Hub, so we have a base to build from. +1. The official docker-library/php Dockerfile for that version exists at some commit, so we can still compile it the same way the official images did. 2. The operating system release underneath still receives security updates from its distribution. Debian LTS counts. Once a release passes its end of life, a rebuild cannot deliver any patches, and Debian removes the packages from its mirrors soon after, so the build fails anyway. When either condition stops holding, we stop rebuilding that image and its existing tags freeze at their last successful build. We do not pin archived or snapshot package repositories to keep an EOL base building: that produces a fresh tag with the same unpatched packages, which is worse than an honest frozen tag. Use an EOL base as a stepping stone, not a destination. See [Choosing an image — Operating Systems](https://serversideup.net/open-source/docker-php/docs/getting-started/choosing-an-image#operating-systems) for the trade-off. @@ -67,7 +73,7 @@ The following images are no longer built. Their existing tags remain pullable on | Anything on Debian Bullseye | 2026-09-03 | Debian 11 LTS ended on 2026-08-31. The following week Debian removed the Bullseye packages from `deb.debian.org` and `security.debian.org`, so `apt-get install` fails inside the build. | | Anything on Alpine 3.16 | 2026-09-03 | Alpine 3.16 reached end of life on 2024-05-23 and has received no security updates since. It was only ever used by PHP 7.4 and 8.0. | -PHP 8.1 is also end of life, but it is still built. Its official base images exist, and Debian Bookworm, Debian Trixie, and Alpine 3.22 still receive security updates, so a weekly rebuild delivers real operating system patches. It stops when those bases do. +PHP 8.1 is end of life and still built. We compile it from the last docker-library/php commit that included it. Debian Bookworm, Debian Trixie, and Alpine 3.22 still receive security updates, so a weekly rebuild delivers real operating system patches. It stops when those bases do. If you are running one of the frozen images, treat it as a migration deadline rather than a stable base. Move to a supported PHP version and base from [Choosing an image](https://serversideup.net/open-source/docker-php/docs/getting-started/choosing-an-image). The [Upgrade Guide](https://serversideup.net/open-source/docker-php/docs/getting-started/upgrade-guide) covers moving between our releases. If you cannot move yet and need to install packages on a frozen Bullseye tag, the [major version migration guide](https://serversideup.net/open-source/docker-php/docs/guide/major-version-migrations#installing-packages-on-a-frozen-bullseye-image) shows how to do it in your own Dockerfile. diff --git a/docs/content/docs/1.getting-started/7.upgrade-guide.md b/docs/content/docs/1.getting-started/7.upgrade-guide.md index 7172b453..3fe2c126 100644 --- a/docs/content/docs/1.getting-started/7.upgrade-guide.md +++ b/docs/content/docs/1.getting-started/7.upgrade-guide.md @@ -20,7 +20,9 @@ Regardless if you are choosing to use floating tags or pinned releases, it is hi All source code merges into the `main` branch. A pre-release is created for beta testing. After internal and community testing, we [publish a release on GitHub](https://github.com/serversideup/docker-php/releases) with a detailed changelog. Publishing a release triggers a production build that pushes a new set of tags to Docker Hub and GitHub Packages. -Production images are also rebuilt **automatically every Tuesday at 08:00 UTC** so floating tags pick up the latest upstream PHP and operating system security patches even when we haven't cut a release that week. +Production images are also rebuilt **automatically every Tuesday at 08:00 UTC** so floating tags pick up the latest operating system security patches even when we haven't cut a release that week. + +New PHP patches don't wait for Tuesday. Every hour, a workflow checks php.net for active PHP releases and starts a production build when a patch has no image yet, so a new patch is usually published within about an hour of php.net listing it, on any day. Like the weekly rebuild, these builds only update floating tags such as `8.5-cli` and `8.5.13-cli`. Version-pinned `-vX.Y.Z` tags are only created when we publish a GitHub release. ### Floating tags vs. version-pinned tags diff --git a/docs/content/docs/1.getting-started/99.contributing.md b/docs/content/docs/1.getting-started/99.contributing.md index 8ef91c2c..7fcc73ae 100644 --- a/docs/content/docs/1.getting-started/99.contributing.md +++ b/docs/content/docs/1.getting-started/99.contributing.md @@ -34,6 +34,12 @@ bash scripts/dev.sh --variation fpm-nginx --version 8.2.12 --os bookworm This will build `serversideup/php:8.2.12-fpm-nginx-bookworm` locally on your machine for testing and inspection. +By default, local builds start from the official PHP image on Docker Hub, which is fast. CI compiles PHP from source instead (see [How PHP is built](#how-php-is-built)). To build exactly what CI builds, add `--build-php-base` with a full patch version. Compiling PHP takes several minutes. The compiled base only exists in your local image store. It needs Docker's default builder and can't be combined with `--push`. + +```bash [Terminal] +bash scripts/dev.sh --variation fpm-nginx --version 8.4.27 --os bookworm --build-php-base +``` + ### Published Beta Images We also have beta images that are published to our Docker Hub and GitHub Packages repositories. @@ -52,15 +58,32 @@ Sometimes you need to just run a test web server locally to see if your changes docker run --rm -v $(pwd):/var/www/html -p 80:8080 -p 443:8443 serversideup/php:8.4-fpm-nginx ``` -## How PHP Versions are selected for distribution -We use the official PHP versions as our base image. To identify which versions should be built, we use a file called `scripts/conf/php-versions-base-config.yml` to explicitly select what versions should be built and any special rules/settings for each version (like base OS, default versions, etc). +## How PHP versions are selected for distribution +The file `scripts/conf/php-versions-base-config.yml` selects which PHP versions are built and the rules for each one, like base OS and default versions. -We then use a `scripts/get-php-versions.sh` script to download the [latest active releases from PHP](https://www.php.net/releases/active.php) and merge them into a final file called `scripts/conf/php-versions.yml`. +The `scripts/get-php-versions.sh` script downloads the [latest active releases from PHP](https://www.php.net/releases/active.php) and merges them into a final file called `scripts/conf/php-versions.yml`. To pin a minor version to a specific patch (or hold back a bad release), list it under that minor's `patch_versions` in the base config. A pinned patch replaces the one from php.net. The `php-versions.yml` file will include all final versions for tagging and building. We generate our tags with a file called `scripts/assemble-docker-tags.sh` which handles all the advanced logic of compiling our tags together. +## How PHP is built +We compile PHP from the php.net tarball with the same Dockerfiles that build the [official PHP images](https://github.com/docker-library/php). A security release can ship as soon as php.net publishes it, without waiting for the official images on Docker Hub. No step of the build depends on PECL. + +For every PHP version, base OS, and upstream variant (`cli`, `fpm`, or `zts`), `scripts/prepare-php-base.sh` downloads docker-library/php at a pinned commit and changes only three things in its Dockerfile: + +- The PHP version, download URL, and SHA256 are set to the patch we're building. The SHA256 comes from php.net, and the GPG signature check stays exactly as upstream runs it. +- The `pecl update-channels` call is removed, so a PECL outage can't fail the build. +- `PHP_BUILD_PROVIDER` names us, so `php -v` shows "Built by Server Side Up (https://serversideup.net/php)". We compile and ship these binaries, so PHP questions should come to us rather than docker-library. + +The build phase compiles each base once and saves it to a private registry, then every variation is built on top of it. Bases are always compiled without the build cache. That way every run picks up the latest Debian and Alpine packages PHP links against, like curl, Brotli, and OpenSSL, without waiting for a new official image. The `redis` extension is built from its GitHub source for the same reason. + +Release candidates work the same way. A version like `8.6-rc` in the base config builds the newest release candidate on php.net's [release candidates feed](https://www.php.net/release-candidates.php?format=json), from docker-library's `8.6-rc` directory, so testers get each RC as soon as php.net publishes it. + +Each variation names the docker-library variant it builds on with `php_base` (`cli`, `fpm`, or `zts`). When upstream changes its variants for a new PHP version, set `php_base_overrides` on that minor version. For example, docker-library builds every 8.6 image with ZTS and has no separate `zts` variant, so 8.6 sets `frankenphp: cli`. + +PHP 8.1 is no longer on docker-library's `master` branch. Its entry in the base config sets `docker_library_ref` to the last commit that had it. Do the same for any other version docker-library drops. + All the scripts above are designed to run locally and in GitHub Actions. Feel free to execute these scripts to see the help menus and how they work. ## GitHub Actions @@ -69,13 +92,15 @@ We use GitHub Actions exclusively to publish all of our releases. If the image e See `.github/workflows/action_publish-images-beta.yml` for an example of how we publish our beta images. ### Linting -Every pull request starts with a `lint` group that runs before any image is built: [actionlint](https://github.com/rhysd/actionlint) for the workflows, [ShellCheck](https://www.shellcheck.net/) for the scripts, and [hadolint](https://github.com/hadolint/hadolint) for the Dockerfiles, plus fixture tests for the helper scripts in `scripts/` that CI relies on. Run the same checks locally before you push: +Every pull request starts with a `lint` group that runs before any image is built: [actionlint](https://github.com/rhysd/actionlint) and [zizmor](https://docs.zizmor.sh/) for the workflows, [ShellCheck](https://www.shellcheck.net/) for the scripts, and [hadolint](https://github.com/hadolint/hadolint) for the Dockerfiles, plus fixture tests for the helper scripts in `scripts/` that CI relies on. Run the same checks locally before you push: ```bash [Terminal] docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest -color +docker run --rm -v "$PWD:/repo:ro" -w /repo ghcr.io/zizmorcore/zizmor:latest .github/workflows + docker run --rm -v "$PWD:/repo" -w /repo koalaman/shellcheck:stable --severity=warning \ - scripts/*.sh scripts/tests/*.sh src/common/usr/local/bin/* src/s6/usr/local/bin/* .oss-scanner/run-variation $(find src -path '*entrypoint.d*' -name '*.sh') + scripts/*.sh scripts/tests/*.sh scripts/tests/image/*.sh src/common/usr/local/bin/* src/s6/usr/local/bin/* .oss-scanner/run-variation $(find src -path '*entrypoint.d*' -name '*.sh') docker run --rm -v "$PWD:/repo" -w /repo hadolint/hadolint:latest hadolint src/variations/*/Dockerfile .oss-scanner/Dockerfile @@ -84,34 +109,65 @@ bash scripts/tests/run.sh The script tests live in one file, `scripts/tests/run.sh`, as plain assertions over a small set of sample images. CI runs the same file on a GitHub runner, whose `jq` and `yq` versions can differ from your machine, so a green run there is the one that counts. +zizmor audits the workflows for security problems such as actions that aren't pinned to a commit SHA, template injection, and broad token permissions. Every action is pinned to a full commit SHA with its version in a comment, such as `actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1`, and Renovate keeps the pins current. Pin any action you add the same way. In CI, zizmor also gets a read-only token so it can check that each pinned SHA belongs to its repository. To run that check locally, set `GH_TOKEN` and add `-e GH_TOKEN` to the command. When you disable a zizmor rule, record it and the reason in `.github/zizmor.yml`. + The Depot runner labels we use are listed in `.github/actionlint.yaml` so actionlint recognizes them. New linters belong in `.github/workflows/service_lint.yml`. +### Image tests +`scripts/test-image.sh` runs an image through the tests in `scripts/tests/image/`, one file per feature area (`php.sh`, `entrypoint.sh`, `laravel-automations.sh`, `web-server.sh`, `ssl.sh`, ...). CI runs it against every image before anything is published. Run it against any image you built or pulled: + +```bash [Terminal] +bash scripts/test-image.sh serversideup/php:8.4-fpm-nginx + +# Run one feature area +TEST_ONLY=web-server bash scripts/test-image.sh serversideup/php:8.4-fpm-nginx +``` + +Every test runs even after one fails, and the summary lists each failure with its output. + +To add a test, add a `test_` function to the file for its feature area, or create a new file in `scripts/tests/image/`. Each function is one test, named after the function. `requires` skips the file when the image doesn't have the feature (or just the test, when you call it inside one; `skip "reason"` skips one for any other reason), and `setup` starts one container that every test in the file shares: + +```bash [scripts/tests/image/web-server.sh] +requires healthcheck web_server + +setup() { + start_container +} + +test_serves_php() { + expect_body / "serversideup-php-ok:${php_version}" +} +``` + +A test passes when it returns and fails when a command fails or it calls `fail "message"`. The helpers (`start_container`, `run_php`, `expect_body`, ...) and the image facts (`php_version`, `http_port`, `container`, ...) live in `scripts/tests/image/lib.sh`. Files the tests mount go in `scripts/tests/image/fixtures/`: `public/` is the web server's document root, `laravel/` is a stand-in Laravel app whose `artisan` records each command it receives, and `entrypoint.d/` holds sample startup scripts. + ### What happens when you open a pull request Every pull request that touches `src/`, `scripts/`, or the workflows builds the full image matrix for both `amd64` and `arm64`. Jobs are grouped by variation, so you can see at a glance whether `cli`, `fpm`, `fpm-apache`, `fpm-nginx`, and `frankenphp` all build. -- **Pull requests from forks** are built on isolated builders and never leave them. Each build job also loads its amd64 image and runs the image tests against it, so a green check means your change builds everywhere and passes the tests on amd64. +- **Pull requests from forks** are built on isolated builders and never leave them. Each build job also loads its amd64 image and runs the image tests against it, so a green check means your change builds everywhere and passes the tests on amd64. Fork builds can't save the compiled PHP base images, so they start from the official PHP images on Docker Hub instead. In the hours after a new PHP release, before Docker Hub has it, fork builds fail. Changes to how PHP is compiled are only tested when a maintainer runs the build for your PR. - **Pull requests from this repository** go through the same three phases as a release: build, test, publish. Images are published to `serversideup/php-dev` with your PR number as the tag prefix (for example `serversideup/php-dev:699-8.4-fpm-nginx-alpine`) only after the image tests pass. A comment on the PR shows the status and the pull commands. - **Every run ends with a summary table** listing each image with its compressed size per architecture, so a size regression is visible before merge. -- **Nothing is published until it passes the image tests.** Every build is saved to a private registry first. Every image is pulled from there on native amd64 and arm64 runners and checked: it starts, runs unprivileged, has no world-writable files or directories, reports the right PHP version, loads the default extensions, becomes healthy, and serves a PHP file through its web server. Only then is the exact same image promoted to Docker Hub, and the published digest is checked against the tested one, so what you pull is what was tested. If any image fails to build or fails its test, nothing from that run is published. You can run the same check locally with `bash scripts/test-image.sh serversideup/php-dev:`. +- **Nothing is published until it passes the image tests.** Every build is saved to a private registry first. Every image is pulled from there on native amd64 and arm64 runners and checked: it starts, runs unprivileged, has no world-writable files or directories, reports the right PHP version, loads the default extensions, becomes healthy, stops cleanly, serves PHP through its web server in every SSL mode and trusted proxy setting, and runs startup scripts and Laravel automations the way the docs describe. Only then is the exact same image promoted to Docker Hub, and the published digest is checked against the tested one, so what you pull is what was tested. If any image fails to build or fails its test, nothing from that run is published. You can run the same check locally with `bash scripts/test-image.sh serversideup/php-dev:`. - **Transient failures heal themselves.** If a few jobs fail because a registry or mirror hiccupped, CI re-runs just those jobs once. If a release or weekly rebuild is still red after that, CI opens an issue for the maintainers with the failed jobs and their errors. - **Maintainers** can publish a fork's images for testing by running the "Docker Publish (PR Images)" workflow manually with the PR number. -## NGINX Versions -We use the official NGINX repos to install the latest version of NGINX for each OS. The version to install is set by a build argument, which is loaded from the `scripts/conf/php-versions-base-config.yml` file. +## Dependency versions +Every version that ships in the image is pinned in `scripts/conf/php-versions-base-config.yml`. Most live under `dependencies`, where each key is passed to the Dockerfiles as a build arg of the same name. The NGINX version for each OS lives under `operating_systems`. PHP itself is the exception: its patch versions come from php.net at build time. -To view the current NGINX versions, run the following command: +[Renovate](https://docs.renovatebot.com/) watches every pin and opens a pull request when a newer version is out. Its Dependency Dashboard issue lists each one with its current and available versions. The rules live in `renovate.json`. -#### View NGINX versions -```bash [Terminal] -./scripts/get-nginx-versions.sh +To update a dependency by hand, edit its line in the base config. To add a new one, add a line under `dependencies` with a `# renovate:` comment above it. Then declare an `ARG` with the same name in each Dockerfile stage that uses it: + +```yaml [scripts/conf/php-versions-base-config.yml] +dependencies: + # renovate: datasource=github-releases depName=just-containers/s6-overlay versioning=loose + S6_VERSION: v3.2.3.2 ``` -This script will look at the official NGINX repos to find the latest version of NGINX for each OS. If you want to update the version, you can run the script with the `--write` flag. +A merged update reaches the published images with the next release. -#### Update NGINX versions -```bash [Terminal] -./scripts/get-nginx-versions.sh --write -``` +## NGINX versions +We install NGINX from the official NGINX repos. Renovate checks the package listings on nginx.org for each OS and updates the `nginx_version` lines in one pull request. ### NGINX repository key verification diff --git a/docs/content/docs/5.guide/7.verifying-images.md b/docs/content/docs/5.guide/7.verifying-images.md new file mode 100644 index 00000000..5288947b --- /dev/null +++ b/docs/content/docs/5.guide/7.verifying-images.md @@ -0,0 +1,76 @@ +--- +head.title: 'Verifying Images - Docker PHP - Server Side Up' +description: 'Check that a serversideup/php image was built and published by our GitHub Actions workflows, and see what is inside it.' +layout: docs +title: Verifying images +--- + +::lead-p +Every image we publish is signed and carries a software bill of materials (SBOM) and build provenance. Use them to confirm that the image you pulled came from our GitHub Actions workflows and to see exactly what went into it. +:: + +## Why verify an image +A tag is only a pointer. Whoever controls it can point `8.5-cli` at a different image. A signature check proves the image was published by the `serversideup/docker-php` repository's own publish workflow, from `main` or a release tag. The SBOM and provenance tell you what is in the image and how it was built, which is what most supply-chain policies and audits ask for. + +::note +Images published before we started signing have provenance but no SBOM or signature. Version-pinned tags such as `8.4-fpm-nginx-v4.3.5` are never rebuilt. Older ones will always fail the signature check. Floating tags like `8.5-cli` are signed from their next rebuild on. +:: + +## Verify the signature +Install cosign by following the [Sigstore installation guide](https://docs.sigstore.dev/cosign/system_config/installation/), then run: + +```bash [Terminal] +cosign verify serversideup/php:8.5-cli \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp '^https://github\.com/serversideup/docker-php/\.github/workflows/service_publish-images\.yml@refs/(heads/main|tags/v.+)$' +``` + +The two flags say who you trust. The issuer is GitHub Actions. The identity is our publish workflow running from `main` or a `v` release tag. cosign exits with an error if the image has no signature from that identity. + +Signing is keyless: there is no public key to download. Each signature is tied to the workflow run that made it through GitHub's OIDC token and recorded in Sigstore's public transparency log. + +Docker Hub and GitHub Packages each hold their own signature. The same command works for `ghcr.io/serversideup/php:8.5-cli`. The signature covers the image index, which references the image for every platform along with its SBOM and provenance. One check covers all of them. + +## Read the SBOM +The SBOM is an [SPDX](https://spdx.dev/) document that lists the packages in the image. Read it for one platform with Docker Buildx: + +```bash [Terminal] +docker buildx imagetools inspect serversideup/php:8.5-cli \ + --format '{{ json (index .SBOM "linux/amd64").SPDX }}' +``` + +Use `linux/arm64` for the arm64 image. On an image published before SBOMs were added, the command prints `null`. + +## Read the build provenance +The provenance is a [SLSA](https://slsa.dev/) record of how the image was built: the source commit, the Dockerfile, the build arguments, and the base images. We publish it in full (`mode=max`): + +```bash [Terminal] +docker buildx imagetools inspect serversideup/php:8.5-cli \ + --format '{{ json (index .Provenance "linux/amd64").SLSA }}' +``` + +To see only the commit and Dockerfile an image was built from, read `invocation.configSource`: + +```bash [Terminal] +docker buildx imagetools inspect serversideup/php:8.5-cli \ + --format '{{ json (index .Provenance "linux/amd64").SLSA.invocation.configSource }}' +``` + +```json [Output] +{ + "digest": { + "sha1": "af1462e3abf8c67ff3fb78141255adc33de5bf2c" + }, + "entryPoint": "src/variations/cli/Dockerfile", + "uri": "https://github.com/serversideup/docker-php.git#af1462e3abf8c67ff3fb78141255adc33de5bf2c" +} +``` + +## Scan it yourself +You don't have to trust our scan. The images are public and work with any scanner. For example, with [Grype](https://github.com/anchore/grype): + +```bash [Terminal] +grype serversideup/php:8.5-cli +``` + +Add `--only-fixed` to list only the findings that have a fix available. Those are the ones a rebuild or an upgrade can change. See [fixable and unfixed findings](/docs/reference/vulnerability-scanning#most-findings-have-no-fix-yet) for why the full list is usually much longer. diff --git a/docs/content/docs/8.reference/2.command-reference.md b/docs/content/docs/8.reference/2.command-reference.md index ddcb1975..55493c6b 100644 --- a/docs/content/docs/8.reference/2.command-reference.md +++ b/docs/content/docs/8.reference/2.command-reference.md @@ -88,8 +88,10 @@ docker-php-serversideup-install-php-extensions "opcache redis zip" "redis=phpred The example above installs `opcache` and `zip` normally, but builds `redis` from the `develop` branch of `phpredis/phpredis`. +Our own image builds set `PHPREDIS_VERSION` and `PHPREDIS_LIBLZF_REF` as build args, so `redis` is built from its GitHub source and the build never depends on PECL. Those build args are not set in your own Dockerfile, so there `redis` installs from PECL as usual unless you pass an override. + ## docker-php-serversideup-install-php-ext-installer -This is an internal helper script to shorten up the syntax for the installation of the PHP extension installer. +This is an internal helper script to shorten up the syntax for the installation of the PHP extension installer. Without a version, it installs the latest release. ```bash [Example - Installing the PHP extension installer] # Usage: docker-php-serversideup-install-php-ext-installer [version] diff --git a/docs/content/docs/8.reference/3.vulnerability-scanning.md b/docs/content/docs/8.reference/3.vulnerability-scanning.md new file mode 100644 index 00000000..a1cd255b --- /dev/null +++ b/docs/content/docs/8.reference/3.vulnerability-scanning.md @@ -0,0 +1,34 @@ +--- +head.title: 'Vulnerability Scanning - Docker PHP - Server Side Up' +description: 'How every published serversideup/php image is scanned for known vulnerabilities, and where to see the results.' +layout: docs +title: Vulnerability scanning +--- + +::lead-p +Every image we publish is scanned for known vulnerabilities after each production build. The results are public, and the numbers to watch are the fixable ones. +:: + +## When and how images are scanned +A scan runs after every production publish. That covers each release, the Tuesday 08:00 UTC weekly rebuild, and the runs that ship new PHP patches. [Grype](https://github.com/anchore/grype) scans the `linux/amd64` image of every published tag, pulled from GitHub Packages. + +The scan job has no access token and no registry credentials, and Grype runs in its own container. A compromised scanner would have nothing to steal and nothing it could publish. + +## See the latest results +Open the [latest successful production run](https://github.com/serversideup/docker-php/actions/workflows/action_publish-images-production.yml?query=is%3Asuccess). Its summary page has a table for each variation with every image's critical, high, and medium counts, and how many findings have a fix. + +Findings that have a fix also become code scanning alerts for the maintainers. Each one closes once a scan no longer finds it. + +## Most findings have no fix yet +A scanner matches every package in the image against public CVE databases. Many of the CVEs it reports are ones the distribution has marked "not fixed" or "won't fix", so there is no newer package to install. A scan of `8.5-cli-bookworm` found 18 critical and 144 high CVEs, and none of them had a fix. + +No rebuild can change an unfixed finding. The fixable count is the meaningful number. The docker-library project explains this in more depth in [Why does my security scanner show that an image has CVEs?](https://github.com/docker-library/faq#why-does-my-security-scanner-show-that-an-image-has-cves) + +## Scan it yourself +You don't have to rely on our scan. The images are public and work with any scanner: + +```bash [Terminal] +grype serversideup/php:8.5-cli --only-fixed +``` + +To report a problem in our own scripts, configs, or workflows, use [private vulnerability reporting](https://github.com/serversideup/docker-php/security/policy) instead. diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..d1cb568f --- /dev/null +++ b/renovate.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended", "helpers:pinGitHubActionDigests"], + "enabledManagers": ["custom.regex", "github-actions", "npm"], + "customManagers": [ + { + "customType": "regex", + "description": "Versions with a '# renovate:' comment on the line above", + "managerFilePatterns": ["scripts/conf/php-versions-base-config.yml", ".github/workflows/service_lint.yml", ".github/workflows/service_scan-images.yml"], + "matchStrings": [ + "#\\s*renovate:\\s*datasource=(?\\S+)\\s+depName=(?\\S+)(?:\\s+versioning=(?\\S+))?\\s*\\n\\s*[A-Z0-9_]+:\\s*\"?(?[^\"\\s@]+)(?:@(?sha256:[a-f0-9]{64}))?\"?" + ] + }, + { + "customType": "regex", + "description": "The docker-library/php commit the PHP base images are compiled from", + "managerFilePatterns": ["scripts/conf/php-versions-base-config.yml"], + "matchStrings": ["DOCKER_LIBRARY_PHP_REF: (?[a-f0-9]{40})"], + "currentValueTemplate": "master", + "depNameTemplate": "docker-library/php", + "packageNameTemplate": "https://github.com/docker-library/php", + "datasourceTemplate": "git-refs" + }, + { + "customType": "regex", + "description": "NGINX package for each Alpine version", + "managerFilePatterns": ["scripts/conf/php-versions-base-config.yml"], + "matchStrings": ["number: (?3\\.\\d+)\\n\\s+nginx_version: (?\\S+)"], + "depNameTemplate": "nginx-alpine{{{packageName}}}", + "datasourceTemplate": "custom.nginx-alpine", + "versioningTemplate": "apk", + "extractVersionTemplate": "^nginx-(?\\d+\\.\\d+\\.\\d+-r\\d+)\\.apk$" + }, + { + "customType": "regex", + "description": "NGINX package for each Debian version", + "managerFilePatterns": ["scripts/conf/php-versions-base-config.yml"], + "matchStrings": ["version: (?[a-z]+)\\n\\s+number: \\d+\\n\\s+nginx_version: (?\\S+)"], + "depNameTemplate": "nginx-{{{packageName}}}", + "datasourceTemplate": "custom.nginx-debian", + "versioningTemplate": "deb", + "extractVersionTemplate": "^nginx_(?\\d+\\.\\d+\\.\\d+-\\d+~{{{packageName}}})_amd64\\.deb$" + } + ], + "customDatasources": { + "nginx-alpine": { + "defaultRegistryUrlTemplate": "https://nginx.org/packages/alpine/v{{packageName}}/main/x86_64/", + "format": "html" + }, + "nginx-debian": { + "defaultRegistryUrlTemplate": "https://nginx.org/packages/debian/pool/nginx/n/nginx/", + "format": "html" + } + }, + "packageRules": [ + { + "description": "Wait three days before proposing a new release, so a hijacked or broken one is usually caught first. git-refs and the nginx.org listings have no release dates, so they are not covered.", + "matchDatasources": ["github-releases", "github-tags", "docker"], + "minimumReleaseAge": "3 days" + }, + { + "description": "zizmor's releases are tagged v1.2.3 but its container image is tagged 1.2.3", + "matchDepNames": ["zizmorcore/zizmor"], + "extractVersion": "^v(?.+)$" + }, + { + "description": "Bump NGINX on every OS in one pull request", + "matchDatasources": ["custom.nginx-alpine", "custom.nginx-debian"], + "groupName": "nginx" + }, + { + "description": "Merge the weekly docker-library/php bump once every check passes. Renovate merges it itself because GitHub's auto-merge would merge right away without required checks.", + "matchDepNames": ["docker-library/php"], + "schedule": ["before 6am on monday"], + "automerge": true, + "platformAutomerge": false + }, + { + "description": "Go and watcher follow FrankenPHP, so its pull request reminds you to bump them together", + "matchDepNames": ["php/frankenphp"], + "prBodyNotes": ["Update `GOLANG_VERSION` in the same pull request to match `GO_VERSION` in [FrankenPHP's docker-bake.hcl](https://github.com/php/frankenphp/blob/v{{{newVersion}}}/docker-bake.hcl), and update `WATCHER_VERSION` to the [watcher release](https://github.com/e-dant/watcher/releases) that was current when this FrankenPHP version shipped."] + }, + { + "description": "The docs site only tracks the project switcher bar", + "matchManagers": ["npm"], + "matchPackageNames": ["!@serversideup/project-switcher-bar"], + "enabled": false + }, + { + "matchManagers": ["github-actions"], + "groupName": "github-actions", + "schedule": ["* 0-3 1 * *"] + }, + { + "description": "Runner images (ubuntu-24.04) are upgraded by hand", + "matchDatasources": ["github-runners"], + "enabled": false + } + ] +} diff --git a/scripts/conf/php-versions-base-config.yml b/scripts/conf/php-versions-base-config.yml index f160a263..f01ef9a9 100644 --- a/scripts/conf/php-versions-base-config.yml +++ b/scripts/conf/php-versions-base-config.yml @@ -1,13 +1,49 @@ +# Every version that ships in the image, other than PHP (read from php.net at build time) +# and NGINX (operating_systems below). Each key is passed to the Dockerfiles as a build arg +# of the same name. Renovate reads the comments and opens +# a pull request when a newer version is out (see renovate.json). +dependencies: + # The docker-library/php commit the PHP base images are compiled from. Renovate moves it + # to the latest commit on master once a week and merges it when every check passes. A minor version can pin its own with docker_library_ref. + DOCKER_LIBRARY_PHP_REF: 5ec2a6a0dad498e89a91a39454dbb52e98ce2bdb + # renovate: datasource=github-releases depName=phpredis/phpredis + PHPREDIS_VERSION: 6.3.0 + # The liblzf commit that phpredis bundles as a submodule. GitHub tarballs leave submodules + # out, so it is downloaded separately. Check it when bumping phpredis: + # gh api repos/phpredis/phpredis/contents/liblzf?ref= --jq .sha + PHPREDIS_LIBLZF_REF: fb25820c3c0aeafd127956ae6c115063b47e459a + # renovate: datasource=github-releases depName=mlocati/docker-php-extension-installer + PHP_EXT_INSTALLER_VERSION: 2.12.6 + # renovate: datasource=github-releases depName=just-containers/s6-overlay versioning=loose + S6_VERSION: v3.2.3.2 + # renovate: datasource=github-releases depName=renatomefi/php-fpm-healthcheck + PHP_FPM_HEALTHCHECK_VERSION: v0.6.0 + # renovate: datasource=github-releases depName=php/frankenphp + FRANKENPHP_VERSION: 1.13.4 + # Go and watcher follow FrankenPHP rather than their own releases, so bump them in the + # same pull request as FRANKENPHP_VERSION. Go matches GO_VERSION in FrankenPHP's + # docker-bake.hcl at that tag. FrankenPHP downloads the latest watcher release when it + # builds, so use the watcher release that was current when that FrankenPHP version shipped. + GOLANG_VERSION: "1.27" + WATCHER_VERSION: 0.14.5 + +# php_base is the docker-library/php variant each variation is built on. A minor version +# can swap it with php_base_overrides (8.6 has no separate zts variant, for example). php_variations: - name: cli + php_base: cli default: true - name: fpm + php_base: fpm - name: fpm-apache + php_base: fpm supported_os: # Open a discussion on serversideup/php if you want to see Alpine support for fpm-apache (https://github.com/serversideup/docker-php/discussions/66) - bookworm - trixie - name: fpm-nginx + php_base: fpm - name: frankenphp + php_base: zts excluded_minor_versions: - "8.1" - "8.2" @@ -16,10 +52,12 @@ php_versions: - major: "8" minor_versions: # PHP 8.1 is end of life, so php.net no longer lists it in the active releases feed - # and the patch version must be pinned here. The official php:8.1.34 images still - # exist on Docker Hub, and Debian Bookworm, Trixie, and Alpine 3.22 still receive - # security updates, so weekly rebuilds keep delivering OS patches. + # and the patch version must be pinned here. docker-library/php removed 8.1 from master, + # so it builds from the last commit that had it ("Update 8.1 to 8.1.34"). Debian Bookworm, + # Trixie, and Alpine 3.22 still receive security updates, so weekly rebuilds keep + # delivering OS patches. - minor: "8.1" + docker_library_ref: 5c037abaf9dc0095e29807c048d9d1b97e6a9716 base_os: - name: alpine3.22 - name: bookworm @@ -58,21 +96,24 @@ php_versions: - name: trixie patch_versions: # - 8.5.8 # Pull latest from Official PHP source - # PHP 8.6 is blocked upstream: the official php:8.6-rc images no longer ship pecl/pear, - # and install-php-extensions does not support 8.6 yet (its pickle fallback is not 8.6 - # compatible), so no PECL extension can be installed. Uncomment once that is resolved. - # https://github.com/serversideup/docker-php/issues/698 - # - # - minor: "8.6-rc" - # base_os: - # - name: alpine3.23 - # - name: alpine3.24 - # - name: bookworm - # - name: trixie - # patch_versions: - # - 8.6-rc - # php_extension_overrides: - # - redis=phpredis/phpredis@develop + # "8.6-rc" builds the newest release candidate php.net lists. docker-library builds + # every 8.6 image with ZTS and has no separate zts variant, so FrankenPHP uses cli. + - minor: "8.6-rc" + base_os: + - name: alpine3.23 + - name: alpine3.24 + - name: bookworm + - name: trixie + patch_versions: + - 8.6-rc + php_base_overrides: + frankenphp: cli + # phpredis 6.3.0 does not compile on PHP 8.6, so 8.6 builds the last develop commit + # before #2959. That change added an enable-redis-phpser build option, and + # install-php-extensions answers phpredis's build prompts in order, so newer commits + # turn on phpser and fail. Remove this once a phpredis release supports PHP 8.6. + php_extension_overrides: + - redis=phpredis/phpredis@810984b5a5aedc37e3ff3c19e33af8dc08c8290f operating_systems: - family: alpine diff --git a/scripts/dev.sh b/scripts/dev.sh index 9a2a3d40..f7993644 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -4,7 +4,7 @@ set -oe pipefail ########################################################################## -# Usage: build.sh --variation --version --os +# Usage: dev.sh --variation --version --os ########################################################################## # This script is used to build a Docker image for a specific PHP version # and variation. It is intended to be used for local development. You may @@ -28,6 +28,7 @@ DOCKER_REPOSITORY="${DOCKER_REPOSITORY:-"serversideup/php"}" DOCKER_ADDITIONAL_BUILD_ARGS=() PUSH_TO_REGISTRY=false PLATFORM="" +BUILD_PHP_BASE=false # UI Colors function ui_set_yellow { @@ -95,21 +96,57 @@ php_minor_version() { fi } +# Compiles PHP the way CI does (see scripts/prepare-php-base.sh) and builds on that +# instead of the official image from Docker Hub. +build_php_base_image() { + if ! [[ "$PHP_BUILD_VERSION" =~ ^[0-9]+\.[0-9]+(\.[0-9]+|-rc)$ ]]; then + echo_color_message red "❌ --build-php-base needs a full patch version (e.g., 8.4.27) or a release candidate (e.g., 8.6-rc), not '$PHP_BUILD_VERSION'" + exit 1 + fi + + local php_base docker_library_ref base_context + php_base=$(VARIATION="$PHP_BUILD_VARIATION" MINOR="$(php_minor_version "$PHP_BUILD_VERSION")" yq -r ' + [.php_versions[].minor_versions[] | select(.minor == env(MINOR)) | .php_base_overrides[env(VARIATION)] // ""] + + [.php_variations[] | select(.name == env(VARIATION)) | .php_base] + | map(select(. != "")) | .[0] + ' "$BASE_PHP_VERSIONS_CONFIG_FILE") + docker_library_ref=$(MINOR="$(php_minor_version "$PHP_BUILD_VERSION")" yq -r ' + [.php_versions[].minor_versions[] | select(.minor == env(MINOR)) | .docker_library_ref // ""] + [.dependencies.DOCKER_LIBRARY_PHP_REF] + | map(select(. != "")) | .[0] + ' "$BASE_PHP_VERSIONS_CONFIG_FILE") + base_context="$PROJECT_ROOT_DIR/.php-base/${PHP_BUILD_VERSION}-${php_base}-${PHP_BUILD_BASE_OS}" + BASE_IMAGE="php-base:${PHP_BUILD_VERSION}-${php_base}-${PHP_BUILD_BASE_OS}" + + bash "$SCRIPT_DIR/prepare-php-base.sh" "$docker_library_ref" "$PHP_BUILD_VERSION" "$PHP_BUILD_BASE_OS" "$php_base" "$base_context" + + echo_color_message yellow "🐳 Compiling PHP base image: $BASE_IMAGE" + docker buildx build \ + --load \ + --platform "$PLATFORM" \ + --tag "$BASE_IMAGE" \ + "$base_context" + echo_color_message green "✅ PHP base image built: $BASE_IMAGE" +} + build_docker_image() { build_tag="${DOCKER_REPOSITORY}:${PHP_BUILD_PREFIX}${PHP_BUILD_VERSION}-${PHP_BUILD_VARIATION}-${PHP_BUILD_BASE_OS}" echo_color_message yellow "🐳 Building Docker Image: $build_tag" - # Set default platform if not specified - if [ -z "$PLATFORM" ]; then - PLATFORM=$(detect_platform) - fi - # Assemble build arguments local build_args=() build_args+=(--build-arg PHP_VARIATION="$PHP_BUILD_VARIATION") build_args+=(--build-arg PHP_VERSION="$PHP_BUILD_VERSION") build_args+=(--build-arg BASE_OS_VERSION="$PHP_BUILD_BASE_OS") + # Every pinned dependency version, the same way CI passes them + while IFS= read -r dependency; do + build_args+=(--build-arg "$dependency") + done < <(yq -r '.dependencies | to_entries[] | "\(.key)=\(.value)"' "$BASE_PHP_VERSIONS_CONFIG_FILE") + + if [ -n "$BASE_IMAGE" ]; then + build_args+=(--build-arg "BASE_IMAGE=$BASE_IMAGE") + fi + if [ -n "$NGINX_VERSION" ] && [ "$PHP_BUILD_VARIATION" = "fpm-nginx" ]; then build_args+=(--build-arg "NGINX_VERSION=$NGINX_VERSION") fi @@ -149,6 +186,8 @@ help_menu() { echo " --prefix Set the prefix for the Docker image (e.g., beta)" echo " --registry Set a custom registry (e.g., localhost:5000)" echo " --platform Set the platform (default: detected from system architecture)" + echo " --build-php-base Compile PHP from source like CI does (needs a full patch version, e.g., 8.4.27, or 8.6-rc," + echo " and the default docker builder, which can use local images)" echo " --push Push the image to the registry" echo " --* Any additional options will be passed to the docker buildx command" } @@ -183,6 +222,10 @@ while [[ $# -gt 0 ]]; do PLATFORM="$2" shift 2 ;; + --build-php-base) + BUILD_PHP_BASE=true + shift + ;; --*) # If there's a next argument and it starts with '--', treat the current argument as standalone. # Otherwise, pair the current argument with the next. @@ -239,4 +282,12 @@ if [ -z "$NGINX_VERSION" ] && [ "$PHP_BUILD_VARIATION" = "fpm-nginx" ]; then echo_color_message green "✅ Using NGINX version '$NGINX_VERSION' for OS '$PHP_BUILD_BASE_OS'" fi -build_docker_image \ No newline at end of file +if [ -z "$PLATFORM" ]; then + PLATFORM=$(detect_platform) +fi + +if [ "$BUILD_PHP_BASE" = true ]; then + build_php_base_image +fi + +build_docker_image diff --git a/scripts/generate-matrix.sh b/scripts/generate-matrix.sh index 3b058e7b..817f684c 100755 --- a/scripts/generate-matrix.sh +++ b/scripts/generate-matrix.sh @@ -36,14 +36,23 @@ yq -o=json "$PHP_VERSIONS_FILE" | jq -c ' ((($variation.supported_os // []) | any(os_family_match($os.name; .)))); . as $root - | [ ($root.php_variations[] | {name, supported_os, excluded_minor_versions}) as $variation + | [ ($root.php_variations[] | {name, php_base, supported_os, excluded_minor_versions}) as $variation | $root.php_versions[] | .minor_versions[] as $minor | select((($variation.excluded_minor_versions // []) | index($minor.minor)) | not) | ($minor.base_os // [])[] as $os | ($minor.patch_versions // [])[] as $patch | select(is_supported($variation; $os)) - | {patch_version: $patch, base_os: $os.name, php_variation: $variation.name, php_extension_overrides: (($minor.php_extension_overrides // []) | join(" "))} + | ((($minor.php_base_overrides // {})[$variation.name]) // $variation.php_base) as $php_base + | { + patch_version: $patch, + base_os: $os.name, + php_variation: $variation.name, + php_extension_overrides: (($minor.php_extension_overrides // []) | join(" ")), + php_base: $php_base, + php_base_tag: "php-base-\($patch)-\($php_base)-\($os.name)", + docker_library_ref: ($minor.docker_library_ref // $root.dependencies.DOCKER_LIBRARY_PHP_REF) + } ] | { include: ( . | sort_by(.patch_version | version_weight) | reverse ) } ' \ No newline at end of file diff --git a/scripts/get-nginx-versions.sh b/scripts/get-nginx-versions.sh deleted file mode 100755 index 93066af1..00000000 --- a/scripts/get-nginx-versions.sh +++ /dev/null @@ -1,268 +0,0 @@ -#!/bin/bash -################################################### -# Usage: get-nginx-versions.sh [--os ] [--write] -################################################### -# This script fetches the latest NGINX versions available for different -# operating systems from the official NGINX repositories. By default, it -# shows all operating systems from the base config file, but you can filter -# to a specific OS if needed. - -set -oe pipefail - -########################## -# Configuration -os_config() { - # Resolve config path relative to this script so --help works before SCRIPT_DIR is set - local this_script_dir - this_script_dir="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" - local config_file="$this_script_dir/conf/php-versions-base-config.yml" - - if ! command -v yq >/dev/null 2>&1; then - echo "yq is required but not found. Install 'yq' (https://github.com/mikefarah/yq) to continue." 1>&2 - return 1 - fi - - # shellcheck disable=SC2016 - yq -r '.operating_systems[] | .family as $f | .versions[] | "\(.version)|\($f)|\(.name)"' "$config_file" \ - | while IFS='|' read -r version family name; do - if [[ "$family" == "alpine" ]]; then - # version comes as alpineX.Y (e.g., alpine3.24) - key="$version" - alpine_num_version="${version#alpine}" - url="http://nginx.org/packages/alpine/v${alpine_num_version}/main/x86_64/" - pattern='nginx-[0-9][^"\n]*\.apk' - else - key="$version" - url="http://nginx.org/packages/debian/dists/${version}/nginx/binary-amd64/Packages" - pattern='^Package: nginx$' - fi - printf '%s|%s|%s|%s\n' "$key" "$name" "$url" "$pattern" - done -} - -########################## -# Functions - -help_menu() { - echo "Usage: $0 [--os ] [--write]" - echo - echo "This script fetches the latest NGINX versions available for different" - echo "operating systems from the PHP base config file." - echo - echo "Options:" - echo " --os Filter to a specific operating system" - echo " --write Write discovered versions back to the base config (yq)" - echo " --help, -h Show this help message" - echo - echo "Available Operating Systems:" - os_config | awk -F'|' '{printf " %-12s %s\n", $1, $2}' - echo - echo "Examples:" - echo " $0 # Show all operating systems" - echo " $0 --os alpine3.24 # Show only Alpine 3.24" - echo " $0 --os bookworm # Show only Debian Bookworm" -} - -########################## -# Argument Parsing - -FILTER_OS="" -WRITE_MODE=false -while [[ "$#" -gt 0 ]]; do - case $1 in - --os) - FILTER_OS="$2" - shift 2 - ;; - --write) - WRITE_MODE=true - shift 1 - ;; - --help|-h) - help_menu - exit 0 - ;; - *) - echo "Unknown parameter passed: $1" - help_menu - exit 1 - ;; - esac -done - -########################## -# Environment Settings - -# Script Configurations -SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" -CONFIG_FILE="$SCRIPT_DIR/conf/php-versions-base-config.yml" - -# UI Colors -function ui_set_yellow { - printf $'\033[0;33m' -} - -function ui_set_green { - printf $'\033[0;32m' -} - -function ui_set_red { - printf $'\033[0;31m' -} - -function ui_set_blue { - printf $'\033[0;34m' -} - -function ui_set_bold { - printf $'\033[1m' -} - -function ui_reset_colors { - printf "\e[0m" -} - -function echo_color_message (){ - color=$1 - message=$2 - - ui_set_$color - echo "$message" - ui_reset_colors -} - -########################## -# Fetch helpers - -get_alpine_version() { - local url="$1" - local pattern="$2" - - local version - version=$(curl -s "$url" | grep -o "$pattern" | sort -V | tail -1) - if [[ -n "$version" ]]; then - # Extract version number from package name (e.g., nginx-1.24.0-r7.apk -> 1.24.0-r7) - echo "$version" | sed 's/nginx-\(.*\)\.apk/\1/' - else - echo "Unable to fetch" - fi -} - -get_debian_version() { - local url="$1" - - local version - version=$(curl -s "$url" \ - | awk 'BEGIN{RS=""; FS="\n"} { pkg=0; ver=""; for (i=1;i<=NF;i++){ if ($i ~ /^Package: nginx$/) pkg=1; if ($i ~ /^Version:/){ split($i,a,": *"); ver=a[2]; } } if (pkg && ver!="") print ver; }' \ - | sort -V | tail -1) - if [[ -n "$version" ]]; then - echo "$version" - else - echo "Unable to fetch" - fi -} - -compute_nginx_version() { - local url="$1" - local pattern="$2" - - local version="" - if [[ "$url" == *"alpine"* ]]; then - version=$(get_alpine_version "$url" "$pattern") - else - version=$(get_debian_version "$url") - fi - - echo "$version" -} - -update_config_nginx_version() { - local version_key="$1" # e.g., alpine3.24 or bookworm - local new_nginx_version="$2" - - if [[ -z "$new_nginx_version" || "$new_nginx_version" == "Unable to fetch" ]]; then - echo_color_message red "⚠️ Skipping update for $version_key (no version found)" - return 0 - fi - - # Preserve comments/spacing: perform a targeted line replacement using awk - # Strategy: when we encounter the specific 'version: ' line, the next - # 'nginx_version:' line in that list item will be replaced with the new value, - # while keeping indentation and any spacing after the colon intact. - local tmp_file - tmp_file="$(mktemp)" - - VERSION_KEY="$version_key" NEW_VER="$new_nginx_version" awk ' - BEGIN { target = ENVIRON["VERSION_KEY"]; replacement = ENVIRON["NEW_VER"]; in_block = 0 } - { - # Detect a version line and check if it matches our target value - if ($0 ~ /^[ \t]*version:[ \t]*/) { - line = $0 - gsub(/^[ \t]*version:[ \t]*/, "", line) - # Extract the first token before a space or comment - split(line, parts, /[ \t#]/) - val = parts[1] - in_block = (val == target) ? 1 : 0 - } - - if (in_block && $0 ~ /^[ \t]*nginx_version:[ \t]*/) { - # Capture exact prefix including key and any spaces after the colon - mpos = match($0, /^[ \t]*nginx_version:[ \t]*/) - if (mpos > 0) { - prefix = substr($0, RSTART, RLENGTH) - print prefix replacement - in_block = 0 - next - } - } - - print $0 - } - ' "$CONFIG_FILE" > "$tmp_file" && mv "$tmp_file" "$CONFIG_FILE" - - echo_color_message green "✍️ Updated nginx_version for $version_key -> $new_nginx_version" -} - -########################## -# Main script starts here - -echo_color_message yellow "🌐 NGINX Version Checker" -echo - -# If a specific OS is requested, validate it exists -if [[ -n "$FILTER_OS" ]]; then - if ! grep -q "^$FILTER_OS|" < <(os_config); then - echo_color_message red "❌ Unknown operating system: $FILTER_OS" - echo - help_menu - exit 1 - fi -fi - -# Process operating systems -os_config | while IFS='|' read -r os_key os_name url pattern; do - # Skip if filtering and this isn't the requested OS - if [[ -n "$FILTER_OS" && "$os_key" != "$FILTER_OS" ]]; then - continue - fi - - echo_color_message blue "🔍 Fetching NGINX version for $os_name from $url..." - version=$(compute_nginx_version "$url" "$pattern") - - ui_set_bold - ui_set_green - printf "%-20s" "$os_name:" - ui_reset_colors - echo " $version" - - if [[ "$WRITE_MODE" == true ]]; then - update_config_nginx_version "$os_key" "$version" - fi -done - -echo -if [[ "$WRITE_MODE" == true ]]; then - echo_color_message green "✅ NGINX version check and write complete!" -else - echo_color_message green "✅ NGINX version check complete!" -fi \ No newline at end of file diff --git a/scripts/get-php-versions.sh b/scripts/get-php-versions.sh index 5808058f..b8b80967 100755 --- a/scripts/get-php-versions.sh +++ b/scripts/get-php-versions.sh @@ -1,23 +1,14 @@ #!/bin/bash ################################################### -# Usage: get-php-versions.sh [--skip-download] [--skip-dockerhub-validation] [--no-fallback] [--input PATH] [--output PATH] +# Usage: get-php-versions.sh [--skip-download] [--input PATH] [--output PATH] ################################################### # This file takes the official latest PHP releases from php.net merges them with our # "base php configuration". These files get merged into a final file called "php-versions.yml" # which is used to build our GitHub Actions jobs. # -# 🔍 DOCKERHUB VALIDATION & FALLBACK -# By default, this script validates that each PHP version from php.net is actually available -# on DockerHub before including it in the final configuration. Every base image our variations -# pull (cli, fpm, zts) is checked on every base OS configured for that minor version, because -# DockerHub publishes those tags in batches and a build fails if any one is missing. A tag only -# counts once it has both linux/amd64 and linux/arm64 images, since DockerHub creates tags empty. -# If a version is not available: -# 1. The script attempts to fall back to the previous patch version (e.g., 8.3.24 -> 8.3.23) -# 2. A GitHub Actions warning is displayed explaining the fallback -# 3. If the fallback version is also unavailable, the script exits with an error -# -# This ensures that Docker builds won't fail due to non-existent base images. +# Images are compiled from the php.net tarball (see scripts/prepare-php-base.sh), so a +# release can be built as soon as php.net lists it. A minor version with patch_versions +# in the base config uses those instead of php.net's, which pins or holds back a release. # # 👉 REQUIRED FILES # - BASE_PHP_VERSIONS_CONFIG_FILE must be valid and set to a valid file path @@ -25,8 +16,6 @@ # # 👉 OPTIONS # --skip-download: Skip downloading from php.net and use existing base config -# --skip-dockerhub-validation: Skip DockerHub validation (useful for testing/development) -# --no-fallback: Fail instead of falling back to the previous patch version (use before a security release) # --input: Input file path (defaults to scripts/conf/php-versions-base-config.yml) # --output: Output file path (defaults to scripts/conf/php-versions.yml) @@ -36,189 +25,13 @@ set -oue pipefail # set -x # trap read DEBUG -########################## -# DockerHub API Functions - -# Check if a PHP version exists on DockerHub -check_dockerhub_php_version() { - local version="$1" - local variant="${2:-cli}" - local os="${3:-}" - - # Our Dockerfiles always pull the OS-suffixed tag. The unsuffixed tag follows - # DockerHub's default OS, which is not necessarily the one we build on. - local image_tag - if [ -n "$os" ]; then - image_tag="${version}-${variant}-${os}" - else - image_tag="${version}-${variant}" - fi - - # Use Docker Hub API v2 to check if the tag exists with timeout and retry - local response body platform - local max_retries=3 - local retry_count=0 - - while [ $retry_count -lt $max_retries ]; do - body=$(mktemp) - response=$(curl -s --max-time 10 --connect-timeout 5 \ - -o "$body" -w "%{http_code}" \ - "https://registry.hub.docker.com/v2/repositories/library/php/tags/${image_tag}/") - - # Check if we got a valid HTTP response - if [ "$response" = "200" ]; then - # DockerHub creates the tag before any architecture is pushed to it, so a tag - # can exist with no images yet. We build for amd64 and arm64, so both must be there. - for platform in amd64 arm64; do - if ! jq -e --arg arch "$platform" 'any(.images[]?; .os == "linux" and .architecture == $arch)' "$body" > /dev/null; then - echo_color_message red "❌ php:${image_tag} exists but has no linux/${platform} image yet" >&2 - rm -f "$body" - return 1 - fi - done - rm -f "$body" - return 0 # Version exists for every platform we build - elif [ "$response" = "404" ]; then - rm -f "$body" - return 1 # Version definitely does not exist - else - rm -f "$body" - # Network error or other issue, retry - retry_count=$((retry_count + 1)) - if [ $retry_count -lt $max_retries ]; then - echo_color_message yellow "⚠️ DockerHub API request failed (HTTP $response), retrying in 2 seconds..." - sleep 2 - fi - fi - done - - # If we get here, all retries failed - echo_color_message red "❌ Failed to check DockerHub after $max_retries attempts for $image_tag" - return 1 -} - -# Check every official base image our variations pull for this version (cli, fpm, zts) -# on every base OS configured for its minor version. One missing tag fails the check. -check_dockerhub_base_images() { - local version="$1" - local minor variant os base_os_list - minor=$(echo "$version" | cut -d'.' -f1-2) - - base_os_list=$(yq -r ".php_versions[].minor_versions[] | select(.minor == \"$minor\") | .base_os[].name" "$BASE_PHP_VERSIONS_CONFIG_FILE") - if [ -z "$base_os_list" ]; then - echo_color_message yellow "⚠️ No base OS configured for PHP $minor. Checking the default cli image only." >&2 - check_dockerhub_php_version "$version" "cli" - return $? - fi - - for variant in cli fpm zts; do - for os in $base_os_list; do - if ! check_dockerhub_php_version "$version" "$variant" "$os"; then - echo_color_message red "❌ Missing on DockerHub: php:${version}-${variant}-${os}" >&2 - return 1 - fi - done - done - return 0 -} - -# Get previous patch version (e.g., 8.3.24 -> 8.3.23) -get_previous_patch_version() { - local version="$1" - local major_minor patch - - # Split version into major.minor and patch - major_minor=$(echo "$version" | cut -d'.' -f1-2) - patch=$(echo "$version" | cut -d'.' -f3) - - # Decrement patch version - if [ "$patch" -gt 0 ]; then - patch=$((patch - 1)) - echo "${major_minor}.${patch}" - else - # If patch is 0, we can't go lower - return 1 - fi -} - -# Add a new function for GitHub Actions annotations (around line 193) -function github_actions_annotation() { - local type="$1" # warning, error, notice, debug - local title="$2" # The title - local message="$3" # The message - - # Output the official workflow command format - echo "::${type} title=${title}::${message}" - - # Add to step summary for better visibility - case "$type" in - warning) - echo "⚠️ **Warning: ${title}** - ${message}" >> "${GITHUB_STEP_SUMMARY:-/dev/null}" - ;; - error) - echo "❌ **Error: ${title}** - ${message}" >> "${GITHUB_STEP_SUMMARY:-/dev/null}" - ;; - esac -} - -# Validate and potentially fallback a PHP version -validate_php_version_with_fallback() { - local version="$1" - local original_version="$version" - local fallback_attempted=false - - echo_color_message yellow "🔍 Checking PHP version $version on DockerHub..." >&2 - - # Check that every base image we build from exists on DockerHub - if check_dockerhub_base_images "$version"; then - echo_color_message green "✅ PHP $version is available on DockerHub" >&2 - echo "$version" # Output to stdout for capture - return 0 - else - echo_color_message red "❌ PHP $version is not available on DockerHub" >&2 - - # Try to get previous patch version - local fallback_version - if [ "$NO_FALLBACK" = true ]; then - github_actions_annotation "error" "PHP Version Unavailable" "PHP $original_version is not available on DockerHub and --no-fallback is set." - return 1 - elif fallback_version=$(get_previous_patch_version "$version"); then - fallback_attempted=true - echo_color_message yellow "⚠️ Attempting fallback to PHP $fallback_version..." >&2 - - if check_dockerhub_base_images "$fallback_version"; then - # Output GitHub Actions annotation without color formatting - github_actions_annotation "warning" "PHP Version Fallback" "PHP $original_version is not available on DockerHub. Falling back to PHP $fallback_version. This may indicate that DockerHub has not yet published the latest PHP release. Consider checking DockerHub availability before updating to newer versions." - echo_color_message green "✅ Fallback successful: Using PHP $fallback_version" >&2 - echo "$fallback_version" # Output to stdout for capture - return 0 - else - echo_color_message red "❌ Fallback version PHP $fallback_version is also not available on DockerHub" >&2 - fi - fi - - # If we get here, both original and fallback failed - if [ "$fallback_attempted" = true ]; then - github_actions_annotation "error" "PHP Version Unavailable" "Neither PHP $original_version nor fallback version $fallback_version are available on DockerHub. This suggests a significant lag in DockerHub publishing or a configuration issue. Please check DockerHub manually and consider using a known working version." - else - github_actions_annotation "error" "PHP Version Unavailable" "PHP $original_version is not available on DockerHub and no fallback version could be determined (patch version is 0). Please check DockerHub manually and use a known working version." - fi - - return 1 - fi -} - ########################## # Argument Parsing SKIP_DOWNLOAD="${SKIP_DOWNLOAD:-false}" -SKIP_DOCKERHUB_VALIDATION="${SKIP_DOCKERHUB_VALIDATION:-false}" -NO_FALLBACK="${NO_FALLBACK:-false}" while [[ "$#" -gt 0 ]]; do case $1 in --skip-download) SKIP_DOWNLOAD=true ;; - --skip-dockerhub-validation) SKIP_DOCKERHUB_VALIDATION=true ;; - --no-fallback) NO_FALLBACK=true ;; --input) if [ -z "$2" ]; then echo "Error: --input requires a file path." @@ -336,74 +149,8 @@ if [ "$SKIP_DOWNLOAD" = false ]; then exit 1 fi - # Parse the fetched JSON data and optionally validate PHP versions on DockerHub - if [ "$SKIP_DOCKERHUB_VALIDATION" = true ]; then - echo_color_message yellow "⚠️ Skipping DockerHub validation as requested..." - processed_json="$php_net_version_json" - else - echo_color_message yellow "🔍 Parsing and validating PHP versions from php.net..." - - # First, extract versions from the JSON - php_versions_raw=$(echo "$php_net_version_json" | jq -r " - . as \$major | - to_entries[] | - .value | - to_entries[] | - .value.version" | grep -v "null" | sort -u) - - # Create temporary files to store validation results - validated_versions_file=$(mktemp) - version_map_file=$(mktemp) - - # Validate each version - validation_failed=false - while IFS= read -r version; do - if [ -n "$version" ]; then - echo_color_message yellow "🔍 Validating PHP $version..." - # Capture validation result without color codes - if validated_version=$(validate_php_version_with_fallback "$version" | tail -n1); then - # Double check that we got a valid version back - if [ -n "$validated_version" ] && [ "$validated_version" != "VALIDATION_FAILED" ]; then - echo "$version:$validated_version" >> "$validated_versions_file" - if [ "$version" != "$validated_version" ]; then - # Escape special characters for sed - escaped_original=$(echo "$version" | sed 's/[[\.*^$(){}?+|/]/\\&/g') - escaped_validated=$(echo "$validated_version" | sed 's/[[\.*^$(){}?+|/]/\\&/g') - echo "s/${escaped_original}/${escaped_validated}/g" >> "$version_map_file" - fi - else - echo_color_message red "❌ Validation failed for PHP $version" - validation_failed=true - fi - else - echo_color_message red "❌ Validation failed for PHP $version" - validation_failed=true - fi - fi - done <<< "$php_versions_raw" - - # Exit if any validation failed - if [ "$validation_failed" = true ]; then - echo_color_message red "❌ One or more PHP versions failed validation. Stopping build." - rm -f "$validated_versions_file" "$version_map_file" - exit 1 - fi - - # Apply version substitutions if any fallbacks were used - processed_json="$php_net_version_json" - if [ -s "$version_map_file" ]; then - echo_color_message yellow "📝 Applying version fallbacks..." - while IFS= read -r substitution; do - processed_json=$(echo "$processed_json" | sed "$substitution") - done < "$version_map_file" - fi - - # Clean up temporary files - rm -f "$validated_versions_file" "$version_map_file" - fi - # Parse the fetched JSON data and transform it to a specific YAML structure using jq and yq. - php_net_yaml_data=$(echo "$processed_json" | jq -r " + php_net_yaml_data=$(echo "$php_net_version_json" | jq -r " { \"php_versions\": [ . as \$major | @@ -434,26 +181,32 @@ if [ "$SKIP_DOWNLOAD" = false ]; then # Use 'echo' to pass the JSON data to 'jq' merged_json=$(jq -s ' { + dependencies: (.[1].dependencies // {}), php_variations: (.[1].php_variations // []), php_versions: ( - .[0].php_versions + .[1].php_versions + (.[0].php_versions + (.[1].php_versions | map(.minor_versions |= map(. + {from_base_config: true})))) | group_by(.major) | map({ major: .[0].major, minor_versions: ( map(.minor_versions[] | select(. != null)) | group_by(.minor) - | map({ - minor: .[0].minor, - base_os: (map(.base_os // []) | add), - patch_versions: (map(.patch_versions // []) | flatten | unique | select(. != null)), - php_extension_overrides: (map(.php_extension_overrides // []) | add | unique) - }) + | map( + (map(select(.from_base_config) | (.patch_versions // [])) | flatten) as $pinned + | { + minor: .[0].minor, + base_os: (map(.base_os // []) | add), + patch_versions: (if ($pinned | length) > 0 then $pinned else (map(.patch_versions // []) | flatten | unique) end), + php_extension_overrides: (map(.php_extension_overrides // []) | add | unique), + docker_library_ref: (map(.docker_library_ref // empty) | first), + php_base_overrides: (map(.php_base_overrides // empty) | add) + } + | with_entries(select(.value != null)) + ) ) }) ), operating_systems: (.[1].operating_systems // []) - } ' <(echo "$downloaded_and_normalized_json_data") <(echo "$base_json_data")) diff --git a/scripts/prepare-php-base.sh b/scripts/prepare-php-base.sh new file mode 100755 index 00000000..3acca746 --- /dev/null +++ b/scripts/prepare-php-base.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: prepare-php-base.sh +# Example: prepare-php-base.sh 5ec2a6a0dad498e89a91a39454dbb52e98ce2bdb 8.4.27 trixie fpm .php-base/8.4.27-fpm-trixie +# prepare-php-base.sh 5ec2a6a0dad498e89a91a39454dbb52e98ce2bdb 8.6-rc trixie cli .php-base/8.6-rc-cli-trixie +# +# A version like "8.6-rc" builds the newest release candidate php.net lists for 8.6. +# Writes the build context for one official PHP image (docker-library/php at a pinned +# commit) set to compile from the php.net tarball. That lets us ship a +# release as soon as php.net publishes it, without waiting for Docker Hub. Only the version +# lines change, "pecl update-channels" is removed so a PECL outage cannot fail the build, and +# `php -v` names us as the builder. Everything else, including the GPG check, is exactly what +# the official images run. + +if [ "$#" -ne 5 ]; then + sed -n '4,5p' "$0" | sed 's/^# //' >&2 + exit 1 +fi + +ref="$1" +patch="$2" +base_os="$3" +php_base="$4" +output_dir="$5" + +scripts_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +download="$scripts_dir/../src/common/usr/local/bin/docker-php-serversideup-download" +work_dir="$(mktemp -d)" +trap 'rm -rf "$work_dir"' EXIT + +build_provider="Server Side Up (https://serversideup.net/php)" + +fail() { + echo "🛑 ERROR (prepare-php-base.sh): $1" >&2 + exit 1 +} + +# docker-library keeps release candidates in their own directory, such as 8.6-rc +if [[ "$patch" == *-rc ]]; then + minor="$patch" + release=$("$download" "https://www.php.net/release-candidates.php?format=json" \ + | jq -c --arg prefix "${patch%-rc}." '[to_entries[] | select(.key | startswith($prefix)) | .value.release | select(.version != null and .files.xz.sha256 != "")][0] // empty') + [ -n "$release" ] || fail "php.net does not list a release candidate for ${patch%-rc}" + version=$(jq -r '.version' <<< "$release") + tarball_url=$(jq -r '.files.xz.path' <<< "$release") + sha256=$(jq -r '.files.xz.sha256' <<< "$release") +else + minor="${patch%.*}" + version="$patch" + tarball_url="https://www.php.net/distributions/php-${patch}.tar.xz" + sha256=$("$download" "https://www.php.net/releases/index.php?json&version=${patch}" \ + | jq -r --arg file "php-${patch}.tar.xz" '(.source // [])[] | select(.filename == $file) | .sha256') +fi +[ -n "$sha256" ] || fail "php.net does not list a SHA256 for PHP ${version}" + +"$download" "https://codeload.github.com/docker-library/php/tar.gz/${ref}" "$work_dir/docker-library-php.tar.gz" +rm -rf "$output_dir" +mkdir -p "$output_dir" +tar -xzf "$work_dir/docker-library-php.tar.gz" -C "$output_dir" --strip-components=4 "php-${ref}/${minor}/${base_os}/${php_base}" \ + || fail "docker-library/php@${ref} has no ${minor}/${base_os}/${php_base}" + +dockerfile="$output_dir/Dockerfile" +sed \ + -e "s|^ENV PHP_VERSION .*|ENV PHP_VERSION ${version}|" \ + -e "s|^ENV PHP_URL=.*|ENV PHP_URL=\"${tarball_url}\" PHP_ASC_URL=\"${tarball_url}.asc\"|" \ + -e "s|^ENV PHP_SHA256=.*|ENV PHP_SHA256=\"${sha256}\"|" \ + -e "s|PHP_BUILD_PROVIDER='[^']*'|PHP_BUILD_PROVIDER='${build_provider}'|" \ + -e '/^[[:space:]]*pecl update-channels;/d' \ + "$dockerfile" > "$dockerfile.new" +mv "$dockerfile.new" "$dockerfile" + +# Check the result rather than the input, so a change in docker-library's layout fails here. +[ "$(grep -c "^ENV PHP_VERSION ${version}\$" "$dockerfile")" = "1" ] || fail "PHP_VERSION was not set to ${version}" +grep -qF "ENV PHP_URL=\"${tarball_url}\"" "$dockerfile" || fail "PHP_URL was not set to ${tarball_url}" +grep -qF "ENV PHP_SHA256=\"${sha256}\"" "$dockerfile" || fail "PHP_SHA256 was not set" +grep -qF "PHP_BUILD_PROVIDER='${build_provider}'" "$dockerfile" || fail "PHP_BUILD_PROVIDER was not set" +if grep -E '^[^#]*pecl' "$dockerfile"; then + fail "the Dockerfile still calls pecl" +fi + +echo "✅ ${output_dir}: docker-library/php ${minor}/${base_os}/${php_base} at ${ref:0:7}, PHP ${version}" diff --git a/scripts/test-image.sh b/scripts/test-image.sh index c35a4270..db969b94 100755 --- a/scripts/test-image.sh +++ b/scripts/test-image.sh @@ -1,85 +1,50 @@ #!/usr/bin/env bash +# shellcheck disable=SC2034,SC2154 # variables are shared with scripts/tests/image/lib.sh and the test files set -euo pipefail # Usage: test-image.sh [expected-php-version] # -# Runs a published or locally built image and checks the things a user would notice -# first: it starts, it runs as an unprivileged user, nothing is world-writable, PHP reports the expected version, -# the default extensions load, and images that ship a web server serve a PHP file -# through it. Works against any image reference. -# Commands go through the image's own entrypoint so every entrypoint.d script runs. -# The entrypoint prints a welcome banner first, so a command's own output is the last line. +# Runs a published or locally built image through the tests in scripts/tests/image/, +# one file per feature area. A file skips itself when the image lacks its feature, +# so the same tests run against every variation. Every test runs even after one +# fails, and the script exits non-zero if any did. +# +# Set TEST_ONLY to a file name without .sh (for example web-server) to run one area. image="${1:?Usage: test-image.sh [expected-php-version]}" expected_php="${2:-}" -health_timeout_seconds=90 -http_timeout_seconds=30 - -# Every variation installs these (DEPENDENCY_PHP_EXTENSIONS in src/variations/*/Dockerfile). -expected_extensions="opcache pcntl pdo_mysql pdo_pgsql redis zip" - -pass() { echo "✅ $1"; } -fail() { echo "❌ $1" >&2; exit 1; } -image_env() { docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$image" | sed -n "s/^$1=//p"; } - -echo "🔎 Testing $image" - -php_version=$(docker run --rm "$image" php -r 'echo PHP_VERSION;' | tail -n1) -if [ -n "$expected_php" ] && [ "$php_version" != "$expected_php" ]; then - fail "PHP reports $php_version, expected $expected_php" -fi -pass "PHP $php_version" - -uid=$(docker run --rm "$image" id -u | tail -n1) -[ "$uid" != "0" ] || fail "Container runs as root by default" -pass "Runs as unprivileged user (uid $uid)" +tests_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/tests/image" && pwd)" -# A COPY without --chmod gives existing destination directories the build context's modes. -# Sticky directories like /tmp are expected. -world_writable=$(docker run --rm --user root --entrypoint sh "$image" -c \ - 'find / -xdev -perm -0002 \( -type d ! -perm -1000 -o -type f \) -print') -[ -z "$world_writable" ] || fail "World-writable paths:"$'\n'"$world_writable" -pass "No world-writable files or directories" +# shellcheck source=tests/image/lib.sh +source "$tests_dir/lib.sh" -loaded_extensions=$(docker run --rm "$image" php -r 'echo implode(" ", array_map("strtolower", get_loaded_extensions()));' | tail -n1) -missing_extensions="" -for extension in $expected_extensions; do - case " $loaded_extensions " in - *" $extension "*) ;; - *"zend $extension "*) ;; - *) missing_extensions="$missing_extensions $extension" ;; - esac -done -[ -z "$missing_extensions" ] || fail "PHP extensions not loaded:$missing_extensions" -pass "Extensions loaded: $expected_extensions" - -# PHP_* environment variables reach php.ini through ${VAR} substitution. Override a few -# of the different value types (size, boolean, list) and confirm PHP sees them. -# OPcache is enabled so the CLI SAPI also allocates the shared cache with the tuned defaults. -ini_values=$(docker run --rm \ - --env PHP_MEMORY_LIMIT=512M \ - --env PHP_REALPATH_CACHE_SIZE=8M \ - --env PHP_SESSION_COOKIE_HTTPONLY=0 \ - --env PHP_DISABLE_FUNCTIONS=shell_exec \ - --env PHP_OPCACHE_ENABLE=1 \ - --env PHP_OPCACHE_FORCE_RESTART_TIMEOUT=60 \ - "$image" php -r 'echo ini_get("memory_limit"), " ", ini_get("realpath_cache_size"), " ", ini_get("session.cookie_httponly"), " ", ini_get("disable_functions"), " ", ini_get("opcache.force_restart_timeout");' | tail -n1) -[ "$ini_values" = "512M 8M 0 shell_exec 60" ] || fail "PHP_* environment variables did not apply to php.ini. Got: $ini_values" -pass "Environment variables apply to php.ini" +test_label="serversideup-php-test=$$" +work_dir=$(mktemp -d) +cleanup() { + local containers images + containers=$(docker ps --all --quiet --filter "label=$test_label") + images=$(docker images --quiet --filter "label=$test_label" | sort -u) + # shellcheck disable=SC2086 # one ID per word + [ -z "$containers" ] || docker rm --force $containers >/dev/null 2>&1 || true + # shellcheck disable=SC2086 + [ -z "$images" ] || docker rmi --force $images >/dev/null 2>&1 || true + rm -rf "$work_dir" +} +trap cleanup EXIT -# PHP_OPCACHE_ENABLE_CLI turns OPcache off for the CLI SAPI while opcache.enable stays on for the web server. -cli_opcache=$(docker run --rm --env PHP_OPCACHE_ENABLE=1 --env PHP_OPCACHE_ENABLE_CLI=0 \ - "$image" php -r 'echo function_exists("opcache_get_status") && opcache_get_status(false) !== false ? "on" : "off";' | tail -n1) -[ "$cli_opcache" = "off" ] || fail "PHP_OPCACHE_ENABLE_CLI=0 did not disable OPcache for the CLI. Got: $cli_opcache" -pass "PHP_OPCACHE_ENABLE_CLI disables the CLI cache" +# The container runs unprivileged, so mounted fixtures must be world readable. +fixtures_dir="$work_dir/fixtures" +cp -R "$tests_dir/fixtures" "$fixtures_dir" +chmod -R u=rwX,go=rX "$fixtures_dir" +web_dir="$fixtures_dir/public" +php_version=$(run_php 'echo PHP_VERSION;') has_healthcheck=$(docker image inspect --format '{{if .Config.Healthcheck}}yes{{end}}' "$image") -if [ -z "$has_healthcheck" ]; then - pass "No HEALTHCHECK defined, skipping startup check" - exit 0 -fi +is_frankenphp=$(image_env CADDY_HTTP_PORT) +is_fpm=$(image_env PHP_FPM_POOL_NAME) -# Web images expose their HTTP port and document root as environment variables. +# Web images expose their HTTP port and document root as environment variables, and +# serve TLS on their HTTPS port once SSL_MODE is on. http_port="" web_root="" for pair in NGINX_HTTP_PORT:NGINX_WEBROOT APACHE_HTTP_PORT:APACHE_DOCUMENT_ROOT CADDY_HTTP_PORT:CADDY_SERVER_ROOT; do @@ -91,229 +56,78 @@ for pair in NGINX_HTTP_PORT:NGINX_WEBROOT APACHE_HTTP_PORT:APACHE_DOCUMENT_ROOT break fi done - -# The same images serve TLS on their HTTPS port once SSL_MODE is on, which is the only -# way to reach them over HTTP/2. https_port="" for variable in NGINX_HTTPS_PORT APACHE_HTTPS_PORT CADDY_HTTPS_PORT; do https_port=$(image_env "$variable") [ -z "$https_port" ] || break done -# Web images run with OPcache enabled so the health check and the served page -# cover the FPM and FrankenPHP SAPIs starting with the tuned defaults. -run_args=(--detach --env PHP_OPCACHE_ENABLE=1) -if [ -n "$http_port" ]; then - # The container runs unprivileged, so the mounted document root must be world readable. - web_dir=$(mktemp -d) - chmod 755 "$web_dir" - echo ' "$web_dir/index.php" - chmod 644 "$web_dir/index.php" - mkdir -p "$web_dir/storage" - chmod 755 "$web_dir/storage" - echo ' "$web_dir/storage/uploaded.php" - chmod 644 "$web_dir/storage/uploaded.php" - # Symfony's Response::send() calls fastcgi_finish_request() and Laravel keeps running - # terminate callbacks after it, so the web server has to survive a request that finishes - # before PHP does. - cat > "$web_dir/finish-request.php" <<'PHP' -/dev/null 2>&1 || true - done - [ -z "${web_dir:-}" ] || rm -rf "$web_dir" -} -trap cleanup EXIT - -dump_container_state() { - echo "--- container logs ---" >&2 - docker logs "$1" >&2 2>&1 || true - echo "--- last health check ---" >&2 - docker inspect --format '{{range .State.Health.Log}}{{.Output}}{{end}}' "$1" 2>/dev/null | tail -5 >&2 || true +failures_file="$work_dir/failures" +touch "$failures_file" + +output="$work_dir/output" + +report() { + local name="$1" status="$2" + case "$status" in + 0) echo "✅ $name" ;; + "$skip_status") echo "⏭️ $name, $(tail -n1 "$output")" ;; + *) + echo "❌ $name" + sed 's/^/ /' "$output" + echo "$area: $name" >> "$failures_file" + ;; + esac } -start_container() { - container=$(docker run "${run_args[@]}" "$@" "$image") - containers+=("$container") - - status=starting - for _ in $(seq 1 "$health_timeout_seconds"); do - status=$(docker inspect --format '{{.State.Health.Status}}' "$container" 2>/dev/null || echo "gone") - case "$status" in - healthy) break ;; - unhealthy|gone) break ;; - esac - sleep 1 - done - - if [ "$status" != "healthy" ]; then - dump_container_state "$container" - fail "Container did not become healthy within ${health_timeout_seconds}s (status: $status)" +# A file stops early when requires skips it or setup fails. +report_file_stopped() { + if [ "$1" -eq "$skip_status" ]; then + echo "⏭️ $(tail -n1 "$output")" + else + report setup "$1" fi - [ -z "$http_port" ] || host_port=$(docker port "$container" "$http_port" | head -n1 | sed 's/.*://') - [ -z "$https_port" ] || https_host_port=$(docker port "$container" "$https_port" | head -n1 | sed 's/.*://') -} - -# Retries until the response body matches, since the web server may still be warming up. -expect_body() { - path="$1" - expected="$2" - body="" - for _ in $(seq 1 "$http_timeout_seconds"); do - body=$(curl --silent --show-error --max-time 5 "http://127.0.0.1:${host_port}${path}" 2>/dev/null || true) - [ "$body" = "$expected" ] && return 0 - sleep 1 - done - dump_container_state "$container" - fail "Web server did not serve ${path} on port ${http_port}. Response: ${body:-}" -} - -# Uploaded PHP files under /storage must never run, including through PATH_INFO -# (/storage/file.php/anything), which Apache and FrankenPHP would otherwise execute. -expect_storage_blocked() { - for path in /storage/uploaded.php /storage/uploaded.php/anything; do - response=$(curl --silent --max-time 5 --output /dev/null --write-out '%{http_code}' "http://127.0.0.1:${host_port}${path}" || true) - [ "$response" = "403" ] || fail "Expected ${path} to return 403, got ${response:-}" - done -} - -# Mercure subscribers pass their JWT as ?authorization=, which must never reach the access log. -# Retries because the log line lands a moment after the response. -expect_authorization_redacted() { - curl --silent --max-time 5 --output /dev/null "http://127.0.0.1:${host_port}/?authorization=octane-secret" || true - for _ in $(seq 1 "$http_timeout_seconds"); do - logs=$(docker logs "$container" 2>&1) - case "$logs" in - *"octane-secret"*) fail "Access log contains the authorization query parameter" ;; - *"authorization=REDACTED"*) return 0 ;; - esac - sleep 1 - done - dump_container_state "$container" - fail "Access log does not redact the authorization query parameter" } -# PHP carrying on after fastcgi_finish_request() has to leave the server able to take the -# next request. Caddy 2.11.6 segfaulted the whole FrankenPHP process here, but only over -# HTTP/2, so both protocol versions are worth checking. -expect_work_after_finish_request() { - for protocol in --http1.1 --http2; do - body="" - for _ in $(seq 1 "$http_timeout_seconds"); do - body=$(curl --silent --insecure "$protocol" --max-time 5 "https://127.0.0.1:${https_host_port}/finish-request.php" 2>/dev/null || true) - [ "$body" = "finish-request-ok:${php_version}" ] && break - sleep 1 - done - if [ "$body" != "finish-request-ok:${php_version}" ]; then - dump_container_state "$container" - fail "Web server did not serve /finish-request.php over ${protocol#--}. Response: ${body:-}" - fi - - # The crash lands after the response, so give the process a moment to fall over. - sleep 2 - status=$(docker inspect --format '{{.State.Status}}' "$container" 2>/dev/null || echo gone) - if [ "$status" != "running" ]; then - dump_container_state "$container" - fail "Web server stopped after a ${protocol#--} request finished before PHP did (status: $status)" - fi +# Runs in its own subshell so one file's containers and variables never reach the next. +# The file and its setup() run in that shell so the tests can use the container setup +# starts; an EXIT trap reports them when they stop it. Each test runs in a subshell with +# errexit on, so a failed command ends that test only. +run_test_file() { + local file="$1" test_function status + exec 3>&1 + trap 'report_file_stopped $? >&3' EXIT + # shellcheck source=/dev/null + source "$file" >"$output" 2>&1 + if declare -F setup >/dev/null; then + setup >"$output" 2>&1 + fi + trap - EXIT + for test_function in $(grep -o '^test_[a-z0-9_]*' "$file"); do + set +e + ( set -e; "$test_function" ) >"$output" 2>&1 + status=$? + set -e + report "$(echo "${test_function#test_}" | tr _ ' ')" "$status" done } -# A detached container has no terminal, so Caddy's default format is json and its default -# stream is stderr. Octane depends on both: it only relays stderr and only parses JSON. -expect_json_logs_on_stderr() { - stdout_logs=$(docker logs "$container" 2>/dev/null) - stderr_logs=$(docker logs "$container" 2>&1 >/dev/null) - case "$stderr_logs" in - *'"msg":"handled request"'*) ;; - *) dump_container_state "$container"; fail "Access log is not JSON on stderr" ;; - esac - case "$stdout_logs" in - *"handled request"*) fail "Access log was written to stdout, which Octane discards" ;; - esac -} - -start_container -pass "Container became healthy" - -[ -n "$http_port" ] || exit 0 - -expect_body / "serversideup-php-ok:${php_version}" -pass "Web server serves PHP on port ${http_port}" - -expect_storage_blocked -pass "Web server blocks PHP execution under /storage" +echo "🔎 Testing $image (PHP $php_version)" +for file in "$tests_dir"/*.sh; do + area=$(basename "$file" .sh) + [ "$area" != lib ] || continue + [ -z "${TEST_ONLY:-}" ] || [ "$area" = "$TEST_ONLY" ] || continue + echo + echo "$area" + set +e + ( set -e; run_test_file "$file" ) + set -e +done -# HTTP/2 needs TLS, so this runs against its own container with SSL_MODE=full, which -# generates a self-signed certificate and stops serving the plain HTTP port. The checks -# after it still expect the container the rest of the suite has been using. -if [ -n "$https_port" ]; then - default_container="$container" - default_host_port="$host_port" - start_container --env SSL_MODE=full - expect_work_after_finish_request - pass "Web server survives a request that finishes before PHP does, over HTTP/1.1 and HTTP/2" - container="$default_container" - host_port="$default_host_port" +echo +if [ -s "$failures_file" ]; then + echo "$(wc -l < "$failures_file" | tr -d ' ') test(s) failed:" >&2 + sed 's/^/ /' "$failures_file" >&2 + exit 1 fi - -# The rest applies to FrankenPHP only: Caddy's log defaults and Laravel Octane. -[ -n "$(image_env CADDY_HTTP_PORT)" ] || exit 0 - -expect_authorization_redacted -expect_json_logs_on_stderr -pass "Web server writes JSON logs to stderr and redacts the authorization query parameter" - -# Octane starts FrankenPHP with LARAVEL_OCTANE=1 and the variables from -# src/Commands/StartFrankenPhpCommand.php in laravel/octane, which must switch -# /etc/frankenphp/Caddyfile to the Octane worker without losing the rest of the configuration. -cat > "$web_dir/frankenphp-worker.php" <<'PHP' -}" -pass "Octane mode exposes the Caddy admin API on Octane's admin port" - -expect_authorization_redacted -expect_json_logs_on_stderr -pass "Octane mode writes JSON logs to stderr and redacts the authorization query parameter" +echo "All tests passed" diff --git a/scripts/tests/image/.shellcheckrc b/scripts/tests/image/.shellcheckrc new file mode 100644 index 00000000..f8238048 --- /dev/null +++ b/scripts/tests/image/.shellcheckrc @@ -0,0 +1,3 @@ +# The test files read and set variables that scripts/test-image.sh and lib.sh share +# with them (image, php_version, container, ...), which ShellCheck can't see across files. +disable=SC2034,SC2154 diff --git a/scripts/tests/image/entrypoint.sh b/scripts/tests/image/entrypoint.sh new file mode 100644 index 00000000..c57c80cd --- /dev/null +++ b/scripts/tests/image/entrypoint.sh @@ -0,0 +1,38 @@ +# shellcheck shell=bash +# docker-php-serversideup-entrypoint: the scripts in /etc/entrypoint.d, then the command. + +# Usage: run_entrypoint