diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index eab96d2ea..000000000 --- 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_lint-docs.yml b/.github/workflows/action_lint-docs.yml new file mode 100644 index 000000000..3f4f05905 --- /dev/null +++ b/.github/workflows/action_lint-docs.yml @@ -0,0 +1,25 @@ +name: Lint Docs + +# The PR image workflow runs the full lint, but it skips pull requests that only change docs. +# This runs the checks that compare the docs reference with src/ for those pull requests. +on: + pull_request: + paths: + - docs/content/** + +permissions: {} + +jobs: + docs-match-src: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Check the docs match src/ + run: bash scripts/lint.sh scripts diff --git a/.github/workflows/action_publish-images-beta.yml b/.github/workflows/action_publish-images-beta.yml index 79fb16ff7..11de8353f 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 e411c5353..34ba5f7ca 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 35b5f765e..aceca9f84 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,12 @@ jobs: with: ref: ${{ inputs.pr_number && format('refs/pull/{0}/head', inputs.pr_number) || github.ref }} authenticate_with_ghcr: false - secrets: inherit + # A manual run signs as main, the identity users verify production images against, + # so the code a maintainer dispatches for a fork's pull request is never signed. + sign: ${{ github.event_name == 'pull_request' }} + 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 +119,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 +136,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 +156,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 10fca4846..3ff986ef8 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 000000000..7b40d0bcc --- /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 c389c3b29..fc48fa722 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 000000000..09196aa8c --- /dev/null +++ b/.github/workflows/scheduled-task_watch-php-releases.yml @@ -0,0 +1,77 @@ +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. + # A minor with no base OS (a new PHP minor php.net lists before the base config does) + # is not built, so starting a run for it would only rebuild everything else. + - 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[] + | select((.base_os // []) | length > 0) + | .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 fa382312c..4f1cb2804 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 4f301bc11..cdaced6f4 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 cd00ea316..bd2cd9038 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 330a7b8c6..e82ed8cf8 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 000000000..d96283311 --- /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 310c9d1bb..8f973cbe0 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 2d1b81435..859b17404 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 b691fe74f..772ba86ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,145 +1,83 @@ # AI Agent Guidelines -This project maintains open source PHP Docker images (`serversideup/php`) for Laravel and other PHP applications. Images are published to Docker Hub and GitHub Packages. These images build on top of the official PHP Docker images with production-grade defaults, security hardening, and a superior developer experience through environment-variable-driven configuration. See `docs/content/docs/1.getting-started/4.these-images-vs-others.md` for the full philosophy. +This repo builds the `serversideup/php` Docker images: official PHP plus production defaults, hardening, and configuration through environment variables. Images publish to Docker Hub and GHCR. The docs site lives in `docs/` and has its own `docs/AGENTS.md`. -## Project Structure +This file lists only the rules that are easy to break. The code, config comments, and workflow files in `.github/workflows/` are the source of truth. When this file disagrees with them, trust the code and fix this file in the same change. -``` -src/ - variations/ # One Dockerfile per image variation (cli, fpm, fpm-apache, fpm-nginx, frankenphp) - common/ # Shared scripts and configs copied into ALL variations - usr/local/bin/ # Entrypoint and helper scripts (POSIX /bin/sh) - etc/entrypoint.d/# Numbered priority entrypoint scripts (00-*, 50-*, etc.) - s6/ # Shared S6 Overlay service definitions and install script - php-fpm.d/ # Shared PHP-FPM pool configuration templates - 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) - 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) -.github/workflows/ # CI/CD with GitHub Actions + Depot for multi-arch builds -``` - -## Shell Script Rules - -**IMPORTANT:** There are two distinct shell environments in this project. Getting this wrong breaks images. +## Shell scripts -- **`src/` scripts** (entrypoint, healthcheck, helper scripts): MUST be POSIX-compliant `/bin/sh`. These run inside Docker containers on both **Debian** and **Alpine** Linux. No bashisms (`[[ ]]`, arrays, `local -n`, process substitution, etc.). -- **`scripts/` directory** (build tooling): Bash (`/bin/bash`). Must work on macOS, Linux, and WSL2. +There are two shell environments. Mixing them up breaks images. -### Common gotchas -- Alpine uses BusyBox `sh`, not `bash`. Commands like `readlink -f`, `sed -i` (without backup extension), and `which` behave differently. -- Use `command -v` instead of `which` in POSIX scripts. -- Use `$(...)` not backticks for command substitution. -- Test OS detection with `[ -f /etc/alpine-release ]` (Alpine) or `[ -f /etc/debian_version ]` (Debian). +- **`src/`** runs inside containers on Debian and Alpine (BusyBox): POSIX `/bin/sh` only. Shellcheck in `scripts/lint.sh` catches bashisms through the `#!/bin/sh` shebang, but not `which` or backticks: use `command -v` and `$(...)`. Detect the OS with `[ -f /etc/alpine-release ]` or `[ -f /etc/debian_version ]`. +- **`scripts/`** is Bash and must work on macOS, Linux, and WSL2. -## Naming Conventions +Naming: container helpers are `docker-php-serversideup-*`, health checks are `healthcheck-*`, and `entrypoint.d/` scripts have a number prefix that sets their order (`sort -V`). -- Container scripts in `src/common/usr/local/bin/` follow the prefix pattern: `docker-php-serversideup-*` (e.g., `docker-php-serversideup-entrypoint`, `docker-php-serversideup-set-file-permissions`). -- Healthcheck scripts use: `healthcheck-*` (e.g., `healthcheck-horizon`, `healthcheck-queue`). -- Entrypoint.d scripts use numbered prefixes for execution order: `0-container-info.sh`, `1-log-output-level.sh`, `50-laravel-automations.sh`. +## Image rules -## Image Architecture +- **One Dockerfile per variation**, shared by Debian and Alpine. OS differences go in helper scripts (`docker-php-serversideup-dep-install-debian`, `-alpine`), never in duplicate Dockerfiles. +- **Dockerfiles download with `docker-php-serversideup-download [output-file]`**, never `curl`, because it retries. `scripts/lint.sh` fails on a `curl` call. Download archives to a file before extracting. +- **Dependency versions live only in `scripts/conf/php-versions-base-config.yml`.** The comments at the top of that file explain how they reach the Dockerfiles. To add one: a config line with a `# renovate:` comment, plus an `ARG` with no default in each stage that uses it. +- **Don't edit `scripts/conf/php-versions.yml`.** It is generated. A minor's `patch_versions` in the base config pins or holds back a release. +- **Keep docker-library/php's Dockerfile untouched** apart from what `scripts/prepare-php-base.sh` already changes. Matching the official images is the point. The `redis` extension builds from GitHub source so no default step depends on PECL. +- **S6 services register** with an empty file in `etc/s6-overlay/user-bundles.d/user/contents.d/`. The older `s6-rc.d/user/contents.d/` makes rc.init write to `/etc` and fails as the unprivileged user. +- Every setting is an environment variable; no runtime config editing. Justify any new package or extension: image size matters. -**There is exactly one Dockerfile per variation.** Each Dockerfile must work across all supported OS bases (Debian and Alpine). OS-specific logic is pushed into shared helper scripts (e.g., `docker-php-serversideup-dep-install-debian`, `docker-php-serversideup-dep-install-alpine`) rather than duplicating Dockerfiles. This keeps maintenance manageable across 8,000+ image tags. +## PHP versions and releases -**Never call `curl` directly in a Dockerfile or build script.** Use `docker-php-serversideup-download [output-file]` from `src/common/`, which retries with backoff. The whole matrix starts at once and unretried downloads fail on transient 5xx responses from GitHub and other hosts. Download archives to a file before extracting them so a retry never feeds a partial stream to `tar`. +- **Release candidates** (`8.6-rc`) resolve through `scripts/get-php-release-candidate.sh` and ship with the weekly rebuild or a release, never the hourly check, which looks only for stable patches. When php.net lists no RC, the run skips it with a warning. Remove the `-rc` minor once the final release is out. +- **When docker-library changes or drops a version**, set `php_base_overrides` or `docker_library_ref` on that minor in the base config. Its comments show the current uses. -Services register with s6-overlay by adding an empty file to `etc/s6-overlay/user-bundles.d/user/contents.d/` (s6-overlay 3.2.3+ layout). The older `s6-rc.d/user/contents.d/` location makes rc.init try to write to `/etc` at startup, which fails as the unprivileged user and leaves the container unhealthy. +## Adding an environment variable -Each variation Dockerfile uses multi-stage builds: -1. Shared assets are `COPY`ed from `src/common/`, `src/s6/`, `src/php-fpm.d/`, and `src/utilities-webservers/` -2. Variation-specific configs live in `src/variations//etc/` -3. The build context is the **project root** (not `src/`), so COPY paths are relative to root: `COPY --chmod=755 src/common/ /` +`docs/content/data/env-vars.yml` is the only place a variable is documented. `scripts/check-env-var-docs.sh` fails CI when it and `src/` disagree, so add or change both in the same change. -### Build args used across Dockerfiles -- `PHP_VERSION`, `BASE_OS_VERSION`, `PHP_VARIATION` -- set by CI matrix or `scripts/dev.sh` -- `NGINX_VERSION` -- resolved per-OS from `scripts/conf/php-versions-base-config.yml` -- `REPOSITORY_BUILD_VERSION` -- image version label +1. Set the default where the code reads it: an `ENV` line in the final stage of each variation that uses it, `: "${NAME:=default}"` in a script, or `{$NAME:default}` in a FrankenPHP Caddyfile. +2. Add the variable to `env-vars.yml`, sorted A-Z. The comment at the top of that file explains each field. Leave out `variations` when every variation reads it, and use `variation_defaults` when one variation's default differs. +3. A variable only build steps or internal scripts read, never users, goes in `INTERNAL_VARIABLES` in `scripts/check-env-var-docs.sh` instead. +4. Run `bash scripts/check-env-var-docs.sh`, and add an image test for the new behavior. -### Variations -| Variation | Web Server | Process Manager | S6 Overlay | -|-----------|-----------|----------------|------------| -| cli | None | None | No | -| fpm | None | PHP-FPM | No | -| fpm-apache | Apache | PHP-FPM | Yes | -| fpm-nginx | NGINX | PHP-FPM | Yes | -| frankenphp | Caddy (FrankenPHP) | Built-in | No | +Helper commands in `/usr/local/bin` work the same way with `docs/content/data/commands.yml` and `scripts/check-command-docs.sh`. Docs pages link to the reference instead of repeating a variable's description. `docs/AGENTS.md` covers when a page may show a default. -## Building Locally -There is a helper script in the `scripts/` directory that will build the image locally. If you attempt to build the image and Docker is not running, tell the user to start Docker Desktop or ensure that the Docker daemon is running before trying again. +## CI rules -```sh -# Requires: Docker with buildx, yq (for fpm-nginx NGINX version resolution) -scripts/dev.sh --variation fpm-nginx --version 8.4 --os bookworm - -# Other examples -scripts/dev.sh --variation cli --version 8.5 --os alpine3.22 -scripts/dev.sh --variation fpm-nginx --version 8.4 --os bookworm --no-cache -scripts/dev.sh --variation frankenphp --version 8.5 --os bookworm --push -``` +- **Publishing is all or nothing**, and users pull exactly the digest that was tested. Don't add a path that publishes an untested image. +- **Don't raise `MAX_PARALLEL`** in `service_publish-images.yml`. GHCR allows 2,000 requests a minute per token and `depot push` doesn't retry a 429. +- **Don't rename or move `service_publish-images.yml`.** Users verify signatures against that path (`docs/content/docs/8.security/2.verifying-images.md`). Manual PR image runs pass `sign: false`. +- **Keep the scanner isolated.** In `service_scan-images.yml`, only the `alerts` job has `security-events: write`, and it never runs Grype. The scan job has no token or registry credentials. +- **`scheduled-task_scorecard.yml`** only publishes while it follows Scorecard's rules: no workflow-level `env`, `defaults`, or write permissions, and only Scorecard's approved actions. +- **New linters go in `scripts/lint.sh`**, with a job in `service_lint.yml` that runs them. Project decisions for zizmor and hadolint are in `.github/zizmor.yml` and `.hadolint.yaml`. Actions are pinned to a full commit SHA with the version in a comment. +- `trigger_auto-retry-failed-builds.yml` finds runs by workflow file, because `workflow_run.name` is the run title. +- Fork PRs build on the official `php:` images, so they never exercise changes to how PHP is compiled. -## PHP Version Pipeline +## Tests -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 +- **Image tests:** `scripts/test-image.sh [php-version]` runs `scripts/tests/image/`, one file per feature area, one `test_*` function per test, helpers in `lib.sh`, mounted files in `fixtures/`. Add tests to the matching area file (or a new file), never to `test-image.sh`. Keep tests generic across variations with `requires` (`healthcheck`, `web_server`, `https`, `frankenphp`, `fpm`). `TEST_ONLY=` runs one area. +- **`scripts/build-summary.sh`** is tested by `scripts/tests/run.sh`; add an assertion with new behavior. Write jq with explicit parentheses (`a + b as $x | ...` parses differently across jq versions, and runners ship an older jq). -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. +## OSS Scanner drift -## CI/CD +`.oss-scanner/Dockerfile` mirrors the variation Dockerfiles, and nothing fails when they drift. Update it in the same change as: -- 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`. -- 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. -- The build matrix is generated from the PHP version pipeline described above. -- Image tags follow the pattern: `serversideup/php:{version}-{variation}` (Debian default) or `serversideup/php:{version}-{variation}-{os}` (Alpine/specific OS). -- 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: - - a variation's `COPY` layout or post-copy permission step: the matching overlay and runtime stages in `.oss-scanner/Dockerfile` - - a new or removed web server variation: its stages in `.oss-scanner/Dockerfile`, and its default command in `run-variation` if it is not `/init` - - a new default PHP version: `PHP_VERSION` in `.oss-scanner/Dockerfile` - - 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` +- a variation's `COPY` layout or post-copy permission step: the matching stages in `.oss-scanner/Dockerfile` +- a new or removed web server variation: its stages, and its command in `.oss-scanner/run-variation` if it isn't `/init` +- a new default PHP version: `PHP_VERSION` in `.oss-scanner/Dockerfile` +- a new way for untrusted input to reach the image, a `SECURITY.md` scope change, or a new intentional default: `.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 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. +Then build it with `docker build -f .oss-scanner/Dockerfile -t oss-scanner .`, run `docker run --rm -it --privileged --network none --user root oss-scanner bash`, start `.oss-scanner/run-variation &`, and check `curl localhost:8080/healthcheck`. -## Verification +## Build and verify -There is no automated test suite for image logic. To verify 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 +Requires Docker with buildx, and `yq`. If Docker isn't running, ask the user to start it. -## Key Design Decisions - -- **Unprivileged by default**: Images run as `www-data`, not root. Web servers listen on `8080`/`8443` (unprivileged ports). -- **Lightweight images**: Only install dependencies that are truly necessary. See `docs/content/docs/1.getting-started/6.default-configurations.md` for what's included and why. When adding packages or extensions, justify the inclusion and keep image size minimal. -- **Environment-variable-driven**: All PHP/FPM/web server configuration is controlled via env vars -- no config file editing at runtime. -- **S6 Overlay** manages multiple processes in web server variations (FPM + web server). -- **Laravel automations** (migrations, caching, etc.) are opt-in via `AUTORUN_ENABLED`. -- **SSL support** is built-in with `SSL_MODE` (off/full) and self-signed cert generation. -- **One Dockerfile per variation**: OS-specific logic belongs in helper scripts, not Dockerfile conditionals or duplicate files. - -## Documentation - -The documentation and marketing site lives in `docs/` and has its own `docs/AGENTS.md` with guidelines specific to the Nuxt 4 content site. When working in `docs/`, follow that file instead of this one. - -## Reference +```sh +scripts/dev.sh --variation fpm-nginx --version 8.5 --os bookworm +scripts/dev.sh --variation cli --version 8.5 --os alpine3.24 --no-cache +bash scripts/test-image.sh serversideup/php:8.5-fpm-nginx-bookworm +``` -When you need details beyond what's in this file, read these local sources rather than guessing: +Pick `--os` from that minor's `base_os` list in the base config. -- **Environment variables** (the canonical reference for ALL env vars, their defaults, and which variations they apply to): `docs/content/docs/8.reference/1.environment-variable-specification.md` -- **Default configurations** (what packages, extensions, and settings ship with each image): `docs/content/docs/1.getting-started/6.default-configurations.md` -- **All documentation**: `docs/content/docs/` contains the full docs in markdown. Browse this directory for guides on image variations, framework integrations, deployment, customization, and troubleshooting. There is also a dedicated `docs/AGENTS.md` file for the documentation site itself -- **LLM-optimized docs** (for AI tools that can fetch URLs): https://serversideup.net/open-source/docker-php/llms.txt and https://serversideup.net/open-source/docker-php/llms-full.txt to view the latest stable versions of the documentation. +1. Build the affected variation and run `scripts/test-image.sh` against it. Add a test for new behavior. +2. Test entrypoint changes on both a Debian and an Alpine build. +3. Run `bash scripts/lint.sh`, the same checks CI runs before it builds anything. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 47dc3e3d8..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md index a1594ef33..5cba4c6ea 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@

Build Status + OpenSSF Scorecard License Support us
@@ -13,7 +14,7 @@ ## Introduction Production-ready PHP Docker images built on official PHP. Optimized for Laravel, WordPress, and modern PHP applications. -### What Makes These Images Different? +### What makes these images different **serversideup/php** takes the official PHP Docker images and adds everything you need for real-world production use: @@ -36,22 +37,22 @@ Production-ready PHP Docker images built on official PHP. Optimized for Laravel, -## Getting Started +## Getting started -### Try it in 2 minutes ⚡ +### Try it in two minutes -Want to see how easy it is? Our installation guide walks you through creating your first PHP app with Docker: +The [quick start guide](https://serversideup.net/open-source/docker-php/docs/getting-started/installation) walks you through your first PHP app with Docker: 1. ✅ Run `phpinfo()` in your browser -2. ✅ Upgrade PHP versions by changing one line -3. ✅ Switch between variations (FPM, FrankenPHP, etc.) -4. ✅ See environment variables in action +2. ✅ Change PHP settings with environment variables +3. ✅ Upgrade or downgrade PHP by changing one line +4. ✅ Switch variations (FPM, FrankenPHP, and more) -**[👉 Follow the quick start guide](https://serversideup.net/open-source/docker-php/docs/getting-started/installation)** +**[Follow the quick start guide →](https://serversideup.net/open-source/docker-php/docs/getting-started/installation)** -### Quick Example +### Quick example -Here's what a complete Laravel setup with NGINX + PHP 8.5 looks like: +A Laravel app with NGINX and PHP 8.5, configured with environment variables: ```yml services: @@ -60,21 +61,14 @@ services: ports: - "80:8080" environment: - # Customize PHP with environment variables - PHP_OPCACHE_ENABLE: "1" PHP_MEMORY_LIMIT: "512M" - - # Laravel automations (migrations, storage link, etc.) + # Run migrations, storage:link, and optimize on start AUTORUN_ENABLED: "true" volumes: - .:/var/www/html ``` -That's it. No complex configs. Just environment variables. - -**Ready to try it?** [Get started with our tutorial →](https://serversideup.net/open-source/docker-php/docs/getting-started/installation) - -## Available Image Variations +## Image variations Choose the variation that fits your needs. All images follow the pattern: @@ -82,7 +76,7 @@ Choose the variation that fits your needs. All images follow the pattern: serversideup/php:{{version}}-{{variation-name}} ``` -### Popular Variations +### Popular variations | Variation | Best For | Example | |-----------|----------|---------| @@ -92,12 +86,12 @@ serversideup/php:{{version}}-{{variation-name}} | **fpm-nginx** | Stable and performant web server | `serversideup/php:8.5-fpm-nginx` | | **frankenphp** | Modern, high-performance apps with worker mode | `serversideup/php:8.5-frankenphp` | -### Supported PHP Versions & Platforms +### Supported PHP versions and platforms > [!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) @@ -109,9 +103,9 @@ We support **PHP 8.2 through 8.5** with both **Debian** and **Alpine** base imag | fpm-apache | **Debian Based**
[![serversideup/php:8.5-fpm-apache](https://img.shields.io/docker/image-size/serversideup/php/8.5-fpm-apache?label=serversideup%2Fphp%3A8.5-fpm-apache)](https://hub.docker.com/r/serversideup/php/tags?name=8.5-fpm-apache&page=1&ordering=-name)
[![serversideup/php:8.4-fpm-apache](https://img.shields.io/docker/image-size/serversideup/php/8.4-fpm-apache?label=serversideup%2Fphp%3A8.4-fpm-apache)](https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-apache&page=1&ordering=-name)
[![serversideup/php:8.3-fpm-apache](https://img.shields.io/docker/image-size/serversideup/php/8.3-fpm-apache?label=serversideup%2Fphp%3A8.3-fpm-apache)](https://hub.docker.com/r/serversideup/php/tags?name=8.3-fpm-apache&page=1&ordering=-name)
[![serversideup/php:8.2-fpm-apache](https://img.shields.io/docker/image-size/serversideup/php/8.2-fpm-apache?label=serversideup%2Fphp%3A8.2-fpm-apache)](https://hub.docker.com/r/serversideup/php/tags?name=8.2-fpm-apache&page=1&ordering=-name) | | fpm-nginx | **Debian Based**
[![serversideup/php:8.5-fpm-nginx](https://img.shields.io/docker/image-size/serversideup/php/8.5-fpm-nginx?label=serversideup%2Fphp%3A8.5-fpm-nginx)](https://hub.docker.com/r/serversideup/php/tags?name=8.5-fpm-nginx&page=1&ordering=-name)
[![serversideup/php:8.4-fpm-nginx](https://img.shields.io/docker/image-size/serversideup/php/8.4-fpm-nginx?label=serversideup%2Fphp%3A8.4-fpm-nginx)](https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-nginx&page=1&ordering=-name)
[![serversideup/php:8.3-fpm-nginx](https://img.shields.io/docker/image-size/serversideup/php/8.3-fpm-nginx?label=serversideup%2Fphp%3A8.3-fpm-nginx)](https://hub.docker.com/r/serversideup/php/tags?name=8.3-fpm-nginx&page=1&ordering=-name)
[![serversideup/php:8.2-fpm-nginx](https://img.shields.io/docker/image-size/serversideup/php/8.2-fpm-nginx?label=serversideup%2Fphp%3A8.2-fpm-nginx)](https://hub.docker.com/r/serversideup/php/tags?name=8.2-fpm-nginx&page=1&ordering=-name)
**Alpine Based**
[![serversideup/php:8.5-fpm-nginx-alpine](https://img.shields.io/docker/image-size/serversideup/php/8.5-fpm-nginx-alpine?label=serversideup%2Fphp%3A8.5-fpm-nginx-alpine)](https://hub.docker.com/r/serversideup/php/tags?name=8.5-fpm-nginx-alpine&page=1&ordering=-name)
[![serversideup/php:8.4-fpm-nginx-alpine](https://img.shields.io/docker/image-size/serversideup/php/8.4-fpm-nginx-alpine?label=serversideup%2Fphp%3A8.4-fpm-nginx-alpine)](https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-nginx-alpine&page=1&ordering=-name)
[![serversideup/php:8.3-fpm-nginx-alpine](https://img.shields.io/docker/image-size/serversideup/php/8.3-fpm-nginx-alpine?label=serversideup%2Fphp%3A8.3-fpm-nginx-alpine)](https://hub.docker.com/r/serversideup/php/tags?name=8.3-fpm-nginx-alpine&page=1&ordering=-name)
[![serversideup/php:8.2-fpm-nginx-alpine](https://img.shields.io/docker/image-size/serversideup/php/8.2-fpm-nginx-alpine?label=serversideup%2Fphp%3A8.2-fpm-nginx-alpine)](https://hub.docker.com/r/serversideup/php/tags?name=8.2-fpm-nginx-alpine&page=1&ordering=-name) | | frankenphp | **Debian Based**
[![serversideup/php:8.5-frankenphp](https://img.shields.io/docker/image-size/serversideup/php/8.5-frankenphp?label=serversideup%2Fphp%3A8.5-frankenphp)](https://hub.docker.com/r/serversideup/php/tags?name=8.5-frankenphp&page=1&ordering=-name)
[![serversideup/php:8.4-frankenphp](https://img.shields.io/docker/image-size/serversideup/php/8.4-frankenphp?label=serversideup%2Fphp%3A8.4-frankenphp)](https://hub.docker.com/r/serversideup/php/tags?name=8.4-frankenphp&page=1&ordering=-name)
[![serversideup/php:8.3-frankenphp](https://img.shields.io/docker/image-size/serversideup/php/8.3-frankenphp?label=serversideup%2Fphp%3A8.3-frankenphp)](https://hub.docker.com/r/serversideup/php/tags?name=8.3-frankenphp&page=1&ordering=-name)
**Alpine Based**
[![serversideup/php:8.5-frankenphp-alpine](https://img.shields.io/docker/image-size/serversideup/php/8.5-frankenphp-alpine?label=serversideup%2Fphp%3A8.5-frankenphp-alpine)](https://hub.docker.com/r/serversideup/php/tags?name=8.5-frankenphp-alpine&page=1&ordering=-name)
[![serversideup/php:8.4-frankenphp-alpine](https://img.shields.io/docker/image-size/serversideup/php/8.4-frankenphp-alpine?label=serversideup%2Fphp%3A8.4-frankenphp-alpine)](https://hub.docker.com/r/serversideup/php/tags?name=8.4-frankenphp-alpine&page=1&ordering=-name)
[![serversideup/php:8.3-frankenphp-alpine](https://img.shields.io/docker/image-size/serversideup/php/8.3-frankenphp-alpine?label=serversideup%2Fphp%3A8.3-frankenphp-alpine)](https://hub.docker.com/r/serversideup/php/tags?name=8.3-frankenphp-alpine&page=1&ordering=-name) | -| unit (deprecated) | ⚠️ NGINX is no longer maintaining NGINX Unit. We stopped providing updates for this image. [Learn more →](https://serversideup.net/open-source/docker-php/docs/image-variations/unit) | +| unit (removed) | ⚠️ NGINX no longer maintains NGINX Unit, so this variation is no longer built. [Learn more →](https://serversideup.net/open-source/docker-php/docs/image-variations/unit) | -## Professional Support +## Professional support Need help integrating Docker with your PHP application? - **[Managed Hosting](https://serversideup.net/hire-us/)** - CI/CD design, managed hosting, guaranteed uptime @@ -120,13 +114,13 @@ Need help integrating Docker with your PHP application? ## Resources - **[Website](https://serversideup.net/open-source/docker-php/)** overview of the product. -- **[Docs](https://serversideup.net/open-source/docker-php/docs)** for a deep-dive on how to use the product. +- **[Docs](https://serversideup.net/open-source/docker-php/docs)** for setup guides and the full reference. - **[Discord](https://serversideup.net/discord)** for friendly support from the community and the team. - **[GitHub](https://github.com/serversideup/docker-php)** for source code, bug reports, and project management. - **[Get Professional Help](https://serversideup.net/professional-support)** - Get video + screen-sharing help directly from the core contributors. ## Contributing -As an open-source project, we strive for transparency and collaboration in our development process. We greatly appreciate any contributions members of our community can provide. Whether you're fixing bugs, proposing features, improving documentation, or spreading awareness - your involvement strengthens the project. Please review our [contribution guidelines](https://serversideup.net/open-source/docker-php/docs/getting-started/contributing) and [code of conduct](./.github/code_of_conduct.md) to understand how we work together respectfully. +Bug fixes, features, and docs improvements are all welcome. Read the [contribution guide](https://serversideup.net/open-source/docker-php/docs/getting-started/contributing) and [code of conduct](./.github/code_of_conduct.md) before opening a pull request. - **Bug Report**: If you're experiencing an issue while using these images, please [create an issue](https://github.com/serversideup/docker-php/issues/new/choose). - **Feature Request**: Make this project better by [submitting a feature request](https://github.com/serversideup/docker-php/discussions/66). diff --git a/SECURITY.md b/SECURITY.md index 429f3e4b9..21a748668 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/upgrading/upgrade-guide#when-images-are-rebuilt) 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/security/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/security/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,10 +57,10 @@ 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. +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#pick-debian-or-alpine) for the trade-off. ## EOL versions and the legacy-modernization path @@ -67,12 +73,12 @@ 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. +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/upgrading/upgrade-guide) covers moving between our releases. If you cannot move yet and need to install packages on a frozen Bullseye tag, the [upgrade guide](https://serversideup.net/open-source/docker-php/docs/upgrading/upgrade-guide#installing-packages-on-a-frozen-bullseye-image) shows how to do it in your own Dockerfile. ## How updates flow -For the full release model, how floating vs. version-pinned tags behave, and how to apply your own patches to a pinned image, see the [Upgrade Guide](https://serversideup.net/open-source/docker-php/docs/getting-started/upgrade-guide). +For the full release model, how floating vs. version-pinned tags behave, and how to apply your own patches to a pinned image, see the [Upgrade Guide](https://serversideup.net/open-source/docker-php/docs/upgrading/upgrade-guide). -For the EOL trade-off when picking an operating system base, see [Choosing an image — Operating Systems](https://serversideup.net/open-source/docker-php/docs/getting-started/choosing-an-image#operating-systems). +For the EOL trade-off when picking an operating system base, see [Choosing an image — Operating Systems](https://serversideup.net/open-source/docker-php/docs/getting-started/choosing-an-image#pick-debian-or-alpine). diff --git a/docs/AGENTS.md b/docs/AGENTS.md index cf969c43c..75f004b58 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -1,187 +1,63 @@ -# AI Agent Guidelines for Documentation +# Documentation guidelines -You are a highly skilled technical writer and PHP system administrator. You are an expert at breaking down complex technical concepts into easy to understand language. You also carry a significant experience in structuring open source documentation to make it easy for others to understand, modify, and contribute to the project. +The docs and marketing site for `serversideup/php`. It is a static Nuxt 4 site built with Nuxt Content and Nuxt UI. Pages are Markdown in `content/docs/`; the home page is `content/index.md`. -## Project Context - -This is the documentation site for **serversideup/php** - production-ready Docker images optimized for PHP applications (especially Laravel). Looking at the project root, you'll see the following directories: -``` -docs/ # Documentation and Marketing site -scripts/ # Scripts used for image building and deployment -src/ # Source code for the PHP Docker images -``` -### Key Technologies in the docs/ directory -- **Nuxt 4** - Vue-based static site generator -- **Nuxt Content** - File-based CMS for markdown documentation -- **Nuxt UI** - Component library for the Nuxt 4 application -- **TailwindCSS** - Utility-first CSS framework - -## Documentation Structure - -The documentation site (located in the docs/ directory) follows this organization: - -``` -app/ # Nuxt app configuration -content/ # Markdown documentation -public/ # Static assets -server/ # Server-side routes for the Nuxt application -``` - -Important note: This application is 100% static and does not require a database or server-side rendering. It is a simple Nuxt 4 application that uses the Nuxt Content module to build the documentation site. The site is then deployed to a static hosting provider like CloudFlare Pages. - - -## Writing Guidelines - -### 1. **Tone and Voice** -- Use clear, conversational language that's professional but approachable -- Write for developers of varying skill levels - beginners to advanced -- Avoid jargon when possible; when technical terms are necessary, explain them -- Use active voice and second person ("you" instead of "one" or "the user") -- Be friendly and approachable, but not too casual. - -### 2. **Content Structure** -- Start with the "why" before the "how" -- Use clear, descriptive headings that follow a logical hierarchy -- Include practical examples that users can copy and run -- Add callouts (notes, warnings, tips) for important information -- Break up long sections with subheadings, lists, and code blocks - -### 3. **Code Examples** -- Always test code examples to ensure they work -- Include comments in complex examples -- Show realistic, production-ready examples when possible -- Specify language syntax highlighting in code blocks -- For Docker examples, use the actual image tags available in the project - -### 4. **Markdown Conventions** -- Use ATX-style headers (# ## ###) not underline style -- Use fenced code blocks with language identifiers -- Use relative links for internal documentation -- Use absolute URLs for external resources -- Include alt text for all images - -### 5. **Docker-Specific Guidelines** -When documenting Docker concepts: -- Show both Docker CLI and Docker Compose examples -- Explain what environment variables do and their default values -- Include health check examples -- Demonstrate volume mounts with real use cases -- Always specify image tags (never use `:latest`) - -### 6. **Laravel-Specific Guidelines** -When documenting Laravel features: -- Reference official Laravel documentation when appropriate -- Show examples using Laravel conventions (Artisan, config, .env) -- Explain automations that the images provide for Laravel -- Document queue, schedule, and Horizon workers properly - -## Content Review Checklist - -Before considering documentation complete, verify: - -- [ ] All code examples are tested and working -- [ ] External links are valid and not broken -- [ ] Spelling and grammar are correct -- [ ] Headings follow logical hierarchy (H1 → H2 → H3) -- [ ] Code blocks have appropriate syntax highlighting -- [ ] Complex concepts include examples or diagrams -- [ ] Callouts (notes/warnings) are used appropriately -- [ ] Cross-references to other docs use relative links -- [ ] Docker image versions match what's actually available -- [ ] Content is accurate to the current version - -## Common Patterns - -### Callout Boxes -Use Markdown callouts for important information: -```markdown -::note -Here's some additional information. -:: - -::tip -Here's a helpful suggestion. -:: - -::warning -Be careful with this action as it might have unexpected results. -:: - -::caution -This action cannot be undone. -:: -``` - -### Code Blocks -Use code blocks to display multi-line code snippets with syntax highlighting. Code blocks are essential for presenting code examples clearly. When writing a code-block, you can specify a filename that will be displayed on top of the code block. An icon will be automatically displayed based on the extension or the name. Filenames help users understand the code's location and purpose within a project. To highlight lines of code, add {} around the line numbers you want to highlight. Line highlighting is useful for focusing users on important parts of code examples. - -```markdown -Here's how to configure PHP-FPM with custom settings: - -\`\`\`ts [nuxt.config.ts]{4-5} -export default defineAppConfig({ - ui: { - icons: { - copy: 'i-lucide-copy', - copyCheck: 'i-lucide-copy-check' - } - } -}) -\`\`\` +```bash +yarn install +yarn dev # http://localhost:3000 +yarn generate # static build; fails on dead internal links ``` -## File Naming Conventions - -- Use numbered prefixes for ordered content: `1.index.md`, `2.installation.md` -- Use kebab-case for file names: `these-images-vs-others.md` -- Keep file names concise but descriptive -- Match file names to the primary H1 heading (URL-friendly version) +## Facts come from the code -## When to Ask Questions +Every option name, default, path, and port must match `src/` and `scripts/conf/php-versions-base-config.yml`. Check the source before writing, never memory or another docs page. -Don't guess or assume when: -- Technical accuracy is in question (Docker config, PHP settings, etc.) -- Breaking changes affect existing documentation -- New features need to be documented but requirements are unclear -- Examples might not work across different OS or environments +Each fact has one home. Other pages link to it instead of repeating it: -## Helpful Resources +| Fact | Page | +|---|---| +| Environment variables, defaults, and which variations use them | `content/data/env-vars.yml`, rendered on `9.reference/1.environment-variable-specification.md`. `scripts/check-env-var-docs.sh` fails CI when it drifts from `src/`. | +| Helper commands, their usage, and which variations ship them | `content/data/commands.yml`, rendered on `9.reference/2.command-reference.md`. `scripts/check-command-docs.sh` fails CI when it drifts from `src/`. | +| Variations, PHP versions, operating systems, and tags | `1.getting-started/5.choosing-an-image.md` | +| Floating and pinned tags, release model | `6.upgrading/1.upgrade-guide.md` | +| SSL modes and certificates | `4.deployment-and-production/2.configuring-ssl.md` | +| The web server health check and `HEALTHCHECK_PATH` | `4.deployment-and-production/3.health-checks.md` | +| Laravel worker health check scripts | `3.framework-guides/1.laravel/3.health-checks.md` | +| Worker stop signals and the `AUTORUN_ENABLED` warning | `3.framework-guides/1.laravel/2.workers.md` | +| Entrypoint script order | `5.customizing-the-image/3.adding-your-own-start-up-scripts.md` | +| OPcache and PHP-FPM tuning | `4.deployment-and-production/5.production-performance-tuning.md` | +| How images are built and published | `8.security/1.how-images-are-built.md` | -- [Official Nuxt Content Documentation](https://content.nuxt.com/) -- [Nuxt UI Docs Template](https://docs-template.nuxt.dev/) -- [Docker Best Practices](https://docs.docker.com/develop/dev-best-practices/) -- [Laravel Documentation](https://laravel.com/docs) -- [serversideup/php GitHub Repository](https://github.com/serversideup/docker-php) -- [Write the Docs Style Guide](https://www.writethedocs.org/guide/writing/style-guides/) +A page may repeat a variable's default in a table that has `Variable` and `Default` columns. Start the cell with `` `value` ``, `` `""` `` for empty, or `Unset`. A short note may follow, such as `` `ondemand`, or `dynamic` in `fpm` ``. `scripts/check-env-var-docs.sh` checks those tables against `env-vars.yml`. It doesn't check defaults mentioned in prose, so mention one only when the sentence depends on it. Upgrade pages are the exception: they say what a default used to be and what it changed to. -## Component Usage +## Writing style -This Nuxt docs site has custom Vue components. Familiarize yourself with: -- `` - Project logo -- `` - Status badges -- `` - Video embeds -- `` - Navigation -- `` - Template selection +- Lead with the point. The first sentence says what the page is for or what the reader gets. One or two sentences of why, then the how. +- One job per page. If the title needs an "and", it is two pages. +- Second person, active voice, imperative steps. Paragraphs of one to four sentences. +- Headings in sentence case that say something: "Custom NGINX rules go in `server-opts.d`", not "NGINX Configuration". +- Tables for options and comparisons, numbered lists or `::steps` for ordered steps. Emoji only where they help a table scan (✅ ❌). +- Docs, not marketing. No unverified numbers, no sales copy, no "seamlessly", "powerful", "simply", "just works". +- Some pages persuade as well as inform, because they decide whether someone switches: `content/index.md`, every page in `1.getting-started/` except Installation, Container basics, and Contributing, plus Laravel Automations, Laravel Health checks, and How images are built. Write them like friendly marketing copy: the reader's problem first, headings that make a point, and one fact per sentence. Every claim still needs a source in the code or from the maintainers. +- US English. Few em dashes. -Check `app/components/` directory for available components before creating new ones. +## Examples -## Testing Changes Locally - -To test documentation changes: -```bash -cd docs/ -yarn install -yarn dev -``` +- Use Compose by default. Add a `docker run` tab with `::code-group` only when it is shorter or the page is about the CLI. +- Use `8.5` tags (`serversideup/php:8.5-fpm-nginx`). Never `latest` or an old patch tag. +- Containers listen on `8080` and `8443`; `fpm` listens on `9000`. Map host ports explicitly (`80:8080`). +- Every code block has a language and, where it is a file, a filename: ` ```yaml [compose.yml] `. +- An example that can't be tested by reading the source gets run against a built image (`scripts/dev.sh`). -Browse to http://localhost:3000 to preview changes. +## Components -## Remember +- Callouts: `::note`, `::tip`, `::warning` (will break), `::caution` (data loss or security). At most one or two per section. +- `::lead-p` for a page's opening paragraph, `::steps{level="4"}`, `::code-group`, `::code-tree{defaultValue="compose.yml"}`. +- Other components live in `app/components/`; check there before inventing markup. -- **Users first**: Always consider what the reader needs to accomplish -- **Clarity over cleverness**: Simple, clear language beats fancy technical writing -- **Examples matter**: Show, don't just tell -- **Accuracy is critical**: Wrong documentation is worse than no documentation -- **Open source mindset**: Make it easy for others to contribute and improve +## Files and links -Your goal is to help users succeed with these Docker images quickly and confidently. \ No newline at end of file +- A number prefix sets the order and is dropped from the URL: `1.getting-started/3.installation.md` is `/docs/getting-started/installation`. +- Every page has `title` and a one-sentence `description` in frontmatter. +- Link to other pages by URL path without prefixes. Full URLs for external links. +- When a page moves or is removed, add its old path to `routeRules` in `nuxt.config.ts` and to `nitro.prerender.routes` so the redirect is generated. diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md deleted file mode 120000 index 47dc3e3d8..000000000 --- a/docs/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/docs/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/docs/app/app.config.ts b/docs/app/app.config.ts index 4646d4997..414f63ed1 100644 --- a/docs/app/app.config.ts +++ b/docs/app/app.config.ts @@ -166,6 +166,12 @@ export default defineAppConfig({ to: 'https://github.com/serversideup/docker-php', target: '_blank' }, + { + icon: 'i-lucide-scroll-text', + label: 'Release Notes', + to: 'https://github.com/serversideup/docker-php/releases', + target: '_blank' + }, { icon: 'i-lucide-bell-ring', label: 'Subscribe', diff --git a/docs/app/assets/css/main.css b/docs/app/assets/css/main.css index eb53465ec..b4386804a 100644 --- a/docs/app/assets/css/main.css +++ b/docs/app/assets/css/main.css @@ -32,19 +32,3 @@ :root { --ui-container: var(--container-8xl); } - -ul li a:after { - margin-left: 6px; -} - -ul ul ul { - border: none !important; -} - -/* ul ul ul li a:after { - margin-left: -13px; -} - -ul li ul li{ - padding-inline-start: 0; -} */ \ No newline at end of file diff --git a/docs/app/components/InlineMarkdown.vue b/docs/app/components/InlineMarkdown.vue new file mode 100644 index 000000000..3c7ba06cc --- /dev/null +++ b/docs/app/components/InlineMarkdown.vue @@ -0,0 +1,44 @@ + + + diff --git a/docs/app/components/LeadP.vue b/docs/app/components/LeadP.vue index 286453e18..d07d6474b 100644 --- a/docs/app/components/LeadP.vue +++ b/docs/app/components/LeadP.vue @@ -1,7 +1,7 @@ + + diff --git a/docs/app/components/ReferenceCopyLink.vue b/docs/app/components/ReferenceCopyLink.vue new file mode 100644 index 000000000..5d9653fa2 --- /dev/null +++ b/docs/app/components/ReferenceCopyLink.vue @@ -0,0 +1,15 @@ + + + diff --git a/docs/app/components/ReferenceFurtherReading.vue b/docs/app/components/ReferenceFurtherReading.vue new file mode 100644 index 000000000..c455da13a --- /dev/null +++ b/docs/app/components/ReferenceFurtherReading.vue @@ -0,0 +1,27 @@ + + + diff --git a/docs/app/components/ReferenceRow.vue b/docs/app/components/ReferenceRow.vue new file mode 100644 index 000000000..58d7dd866 --- /dev/null +++ b/docs/app/components/ReferenceRow.vue @@ -0,0 +1,66 @@ + + + diff --git a/docs/app/components/ReferenceSearch.vue b/docs/app/components/ReferenceSearch.vue new file mode 100644 index 000000000..13bb861de --- /dev/null +++ b/docs/app/components/ReferenceSearch.vue @@ -0,0 +1,42 @@ + + + diff --git a/docs/app/components/ReferenceUsedBy.vue b/docs/app/components/ReferenceUsedBy.vue new file mode 100644 index 000000000..9a9b248f6 --- /dev/null +++ b/docs/app/components/ReferenceUsedBy.vue @@ -0,0 +1,33 @@ + + + diff --git a/docs/app/components/content/CommandExplorer.vue b/docs/app/components/content/CommandExplorer.vue new file mode 100644 index 000000000..488dde8e6 --- /dev/null +++ b/docs/app/components/content/CommandExplorer.vue @@ -0,0 +1,214 @@ + + + diff --git a/docs/app/components/content/EnvVarExplorer.vue b/docs/app/components/content/EnvVarExplorer.vue new file mode 100644 index 000000000..4b50a2d3b --- /dev/null +++ b/docs/app/components/content/EnvVarExplorer.vue @@ -0,0 +1,310 @@ + + + diff --git a/docs/app/components/content/YoutubeVideo.vue b/docs/app/components/content/YoutubeVideo.vue new file mode 100644 index 000000000..7619e7c29 --- /dev/null +++ b/docs/app/components/content/YoutubeVideo.vue @@ -0,0 +1,22 @@ + + +