diff --git a/.github/workflows/action_publish-images-beta.yml b/.github/workflows/action_publish-images-beta.yml
index d741829d..353c3ab7 100644
--- a/.github/workflows/action_publish-images-beta.yml
+++ b/.github/workflows/action_publish-images-beta.yml
@@ -83,7 +83,6 @@ jobs:
registry-repositories: "docker.io/serversideup/php,ghcr.io/serversideup/php"
tag-prefix: "beta"
release-type: "testing"
- secrets: inherit
test:
needs: [release, setup, base, build]
@@ -106,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]
diff --git a/.github/workflows/action_publish-images-production.yml b/.github/workflows/action_publish-images-production.yml
index df154724..0c1f81c0 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:
@@ -83,7 +89,6 @@ jobs:
registry-repositories: "docker.io/serversideup/php,ghcr.io/serversideup/php"
tag-prefix: ''
release-type: "latest"
- secrets: inherit
test:
needs: [release, setup, base, build]
@@ -106,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]
diff --git a/.github/workflows/action_publish-images-prs.yml b/.github/workflows/action_publish-images-prs.yml
index e64c7bd3..c79bcb81 100644
--- a/.github/workflows/action_publish-images-prs.yml
+++ b/.github/workflows/action_publish-images-prs.yml
@@ -81,7 +81,6 @@ jobs:
github.event_name == 'workflow_dispatch' ||
github.event.pull_request.head.repo.full_name == github.repository
}}
- secrets: inherit
test:
needs: [setup, base, build]
@@ -105,7 +104,9 @@ jobs:
with:
ref: ${{ inputs.pr_number && format('refs/pull/{0}/head', inputs.pr_number) || github.ref }}
authenticate_with_ghcr: false
- secrets: inherit
+ secrets:
+ DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
+ DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
report:
needs: [setup, build, test, publish]
diff --git a/.github/workflows/action_update-dockerhub-readme.yml b/.github/workflows/action_update-dockerhub-readme.yml
index 10fca484..3ff986ef 100644
--- a/.github/workflows/action_update-dockerhub-readme.yml
+++ b/.github/workflows/action_update-dockerhub-readme.yml
@@ -8,18 +8,23 @@ on:
paths:
- README.md
+permissions: {}
+
jobs:
update_container_readme:
runs-on: ubuntu-24.04
name: Push README to Docker Hub
+ permissions:
+ contents: read
steps:
- name: git checkout
- uses: actions/checkout@v7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
+ persist-credentials: false
ref: main
- name: push README to Dockerhub
- uses: peter-evans/dockerhub-description@v5
+ uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
with:
username: ${{ secrets.DOCKER_HUB_README_USERNAME }}
password: ${{ secrets.DOCKER_HUB_README_PASSWORD }}
diff --git a/.github/workflows/scheduled-task_scorecard.yml b/.github/workflows/scheduled-task_scorecard.yml
new file mode 100644
index 00000000..7b40d0bc
--- /dev/null
+++ b/.github/workflows/scheduled-task_scorecard.yml
@@ -0,0 +1,46 @@
+name: OpenSSF Scorecard
+
+# Scores the repository's supply chain practices (pinned dependencies, token permissions,
+# signed releases, branch protection, and more) and publishes the result to
+# scorecard.dev, which serves the badge in the README. Publishing only works while this
+# workflow follows Scorecard's rules: no workflow-level env, defaults, or write
+# permissions, and only Scorecard's approved actions in the job.
+on:
+ workflow_dispatch:
+ branch_protection_rule:
+ push:
+ branches:
+ - main
+ schedule:
+ - cron: '41 6 * * 3'
+
+permissions: {}
+
+jobs:
+ analysis:
+ # Forks inherit the cron schedule. Their weekly rebuilds always fail and email whoever last edited the schedule upstream.
+ if: github.event_name != 'schedule' || github.repository == 'serversideup/docker-php'
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ actions: read
+ security-events: write
+ id-token: write
+ steps:
+ - name: Check out code
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Run Scorecard
+ uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0
+ with:
+ results_file: results.sarif
+ results_format: sarif
+ publish_results: true
+
+ - name: Upload the results to the Security tab
+ uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
+ with:
+ sarif_file: results.sarif
diff --git a/.github/workflows/scheduled-task_update-readme.yml b/.github/workflows/scheduled-task_update-readme.yml
index c389c3b2..fc48fa72 100644
--- a/.github/workflows/scheduled-task_update-readme.yml
+++ b/.github/workflows/scheduled-task_update-readme.yml
@@ -7,6 +7,6 @@ jobs:
update-readme:
# Forks inherit the cron schedule. Their weekly rebuilds always fail and email whoever last edited the schedule upstream.
if: github.event_name != 'schedule' || github.repository == 'serversideup/docker-php'
- uses: serversideup/.github/.github/workflows/update-readme.yml@master
+ uses: serversideup/.github/.github/workflows/update-readme.yml@0cc5de42120438871a6c36a91b6a7dc2a15dbd7f # v1.0.1
permissions:
contents: write
diff --git a/.github/workflows/scheduled-task_watch-php-releases.yml b/.github/workflows/scheduled-task_watch-php-releases.yml
new file mode 100644
index 00000000..26314bf0
--- /dev/null
+++ b/.github/workflows/scheduled-task_watch-php-releases.yml
@@ -0,0 +1,74 @@
+name: Watch for PHP releases
+
+# PHP is compiled from php.net, so a new patch can ship as soon as php.net lists it.
+# Every hour this resolves the active patches the same way a build does, and starts a
+# production run when one of them has no image on Docker Hub yet. Like the weekly
+# rebuild, that run rebuilds the latest release and only moves the floating tags.
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: '17 * * * *'
+
+permissions: {}
+
+jobs:
+ watch:
+ # Forks inherit the cron schedule. Their weekly rebuilds always fail and email whoever last edited the schedule upstream.
+ if: github.event_name != 'schedule' || github.repository == 'serversideup/docker-php'
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ actions: write
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ PRODUCTION_WORKFLOW: action_publish-images-production.yml
+ DOCKER_HUB_REPOSITORY: serversideup/php
+ # A run that started this recently either is still going or already opened a ci-failure issue.
+ COOLDOWN_HOURS: 6
+ steps:
+ - name: Check out code
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Resolve the active PHP patches
+ run: ./scripts/get-php-versions.sh
+
+ # Each patch is looked up as its cli tag on the first OS it builds for. Anything other
+ # than "found" or "not found" is skipped, so a Docker Hub outage never starts a run.
+ - name: Find patches that have no image yet
+ id: missing
+ run: |
+ MISSING=()
+ while IFS=$'\t' read -r PATCH OS; do
+ TAG="${PATCH}-cli-${OS}"
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' --retry 3 --retry-delay 5 \
+ "https://hub.docker.com/v2/namespaces/${DOCKER_HUB_REPOSITORY%/*}/repositories/${DOCKER_HUB_REPOSITORY#*/}/tags/${TAG}")
+ case "$STATUS" in
+ 200) echo "$PATCH is published ($TAG)" ;;
+ 404) echo "$PATCH has no image yet ($TAG)"; MISSING+=("$PATCH") ;;
+ *) echo "::warning title=Docker Hub lookup failed::$TAG returned HTTP $STATUS, skipping $PATCH this hour" ;;
+ esac
+ done < <(yq -o=json scripts/conf/php-versions.yml | jq -r '
+ .php_versions[].minor_versions[]
+ | .base_os[0].name as $os
+ | .patch_versions[] | select(test("rc") | not)
+ | [., $os] | @tsv')
+ echo "patches=${MISSING[*]}" >> "$GITHUB_OUTPUT"
+
+ - name: Start a production run
+ if: steps.missing.outputs.patches != ''
+ env:
+ PATCHES: ${{ steps.missing.outputs.patches }}
+ run: |
+ RECENT=$(gh run list --workflow "$PRODUCTION_WORKFLOW" --limit 10 --json status,createdAt,url \
+ | jq -r --argjson hours "$COOLDOWN_HOURS" \
+ '[.[] | select(.status != "completed" or ((.createdAt | fromdateiso8601) > (now - $hours * 3600)))][0].url // ""')
+ if [ -n "$RECENT" ]; then
+ echo "::notice title=Already building::$RECENT started in the last ${COOLDOWN_HOURS} hours, so no new run was started for PHP $PATCHES."
+ exit 0
+ fi
+ gh workflow run "$PRODUCTION_WORKFLOW" -f reason="PHP ${PATCHES// /, } release"
+ echo "::notice title=Production run started::PHP $PATCHES has no published image yet."
diff --git a/.github/workflows/service_build-images.yml b/.github/workflows/service_build-images.yml
index aae69514..4f1cb280 100644
--- a/.github/workflows/service_build-images.yml
+++ b/.github/workflows/service_build-images.yml
@@ -59,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: |
@@ -92,35 +95,43 @@ 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"
@@ -152,7 +163,7 @@ jobs:
# onto the runner for the test step below instead.
- name: Build images
id: build
- uses: depot/build-push-action@v1
+ uses: depot/build-push-action@0f27be7c41d76663a98518dfe68f1c46582242c4 # v1.19.0
with:
file: src/variations/${{ matrix.php_variation }}/Dockerfile
build-args: |
@@ -171,6 +182,11 @@ jobs:
save: ${{ inputs.publish }}
save-tags: ${{ github.run_id }}-${{ env.IMAGE_NAME }}
load: ${{ !inputs.publish }}
+ # Saved images carry an SBOM and full build provenance in their index, so the
+ # signature service_publish-images.yml puts on the index covers both. An image
+ # loaded onto the runner cannot hold attestations.
+ sbom: ${{ inputs.publish }}
+ provenance: ${{ inputs.publish && 'mode=max' || 'false' }}
tags: ${{ env.DOCKER_TAGS }}
outputs: type=image,name=target,annotation-index.org.opencontainers.image.description=Supercharge your PHP experience with Docker images that are based off the official PHP images but are optimized to be run in production environments for Laravel and WordPress and more
@@ -179,7 +195,9 @@ jobs:
# dispatches the workflow for the pull request.
- name: Test the image (amd64)
if: ${{ !inputs.publish }}
- run: bash scripts/test-image.sh "${DOCKER_TAGS%%,*}" "${{ matrix.patch_version }}"
+ env:
+ PHP_VERSION: ${{ matrix.patch_version }}
+ run: bash scripts/test-image.sh "${DOCKER_TAGS%%,*}" "$PHP_VERSION"
# The test, publish, and report workflows all read these files.
- name: Record image details
@@ -187,6 +205,9 @@ jobs:
PUBLISH: ${{ inputs.publish }}
BUILD_ID: ${{ steps.build.outputs.build-id }}
DIGEST: ${{ steps.build.outputs.digest }}
+ VARIATION: ${{ matrix.php_variation }}
+ PHP_VERSION: ${{ matrix.patch_version }}
+ BASE_OS: ${{ matrix.base_os }}
run: |
mkdir -p image-details
SAVED=null
@@ -194,9 +215,9 @@ jobs:
SAVED="\"registry.depot.dev/$(jq -r .id depot.json):${{ github.run_id }}-${IMAGE_NAME}\""
fi
jq -n \
- --arg variation "${{ matrix.php_variation }}" \
- --arg php "${{ matrix.patch_version }}" \
- --arg os "${{ matrix.base_os }}" \
+ --arg variation "$VARIATION" \
+ --arg php "$PHP_VERSION" \
+ --arg os "$BASE_OS" \
--arg tags "$DOCKER_TAGS" \
--arg build_id "$BUILD_ID" \
--arg digest "$DIGEST" \
@@ -205,7 +226,7 @@ jobs:
| tee "image-details/${IMAGE_NAME}.json"
- name: Upload image details
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: image-details-${{ env.IMAGE_NAME }}
path: image-details/
diff --git a/.github/workflows/service_build-php-base.yml b/.github/workflows/service_build-php-base.yml
index eabc073a..a0592b5a 100644
--- a/.github/workflows/service_build-php-base.yml
+++ b/.github/workflows/service_build-php-base.yml
@@ -37,12 +37,13 @@ jobs:
matrix: ${{ fromJson(inputs.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
# The context lives inside the checkout so Depot finds depot.json above it.
- name: Prepare the docker-library/php build context
@@ -58,7 +59,7 @@ jobs:
# Brotli, OpenSSL) in its first layers, and a cached layer would keep shipping old
# versions until Debian or Alpine publish a new base image.
- name: Compile and save the PHP base image
- uses: depot/build-push-action@v1
+ uses: depot/build-push-action@0f27be7c41d76663a98518dfe68f1c46582242c4 # v1.19.0
with:
context: .php-base/${{ matrix.php_base_tag }}
platforms: |
diff --git a/.github/workflows/service_lint.yml b/.github/workflows/service_lint.yml
index 1e9b8012..22c3ec35 100644
--- a/.github/workflows/service_lint.yml
+++ b/.github/workflows/service_lint.yml
@@ -12,6 +12,8 @@ env:
SHELLCHECK_VERSION: v0.11.0
# renovate: datasource=github-releases depName=hadolint/hadolint
HADOLINT_VERSION: v2.15.1
+ # renovate: datasource=github-releases depName=zizmorcore/zizmor
+ ZIZMOR_VERSION: 1.31.1
jobs:
actionlint:
@@ -21,11 +23,32 @@ jobs:
contents: read
steps:
- name: Check out code
- uses: actions/checkout@v7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
- name: Lint GitHub Actions workflows
run: docker run --rm -v "$PWD:/repo" -w /repo "rhysd/actionlint:${ACTIONLINT_VERSION}" -color
+ # Security audit for the workflows: unpinned actions, template injection, risky triggers,
+ # and broad permissions. Its project decisions are recorded in .github/zizmor.yml. The
+ # token is read-only and lets it check that every pinned SHA belongs to its repository.
+ zizmor:
+ 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: Audit GitHub Actions workflows
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: docker run --rm -e GH_TOKEN -v "$PWD:/repo:ro" -w /repo "ghcr.io/zizmorcore/zizmor:${ZIZMOR_VERSION}" --color=always .github/workflows
+
shellcheck:
runs-on: ubuntu-24.04
timeout-minutes: 5
@@ -33,7 +56,9 @@ jobs:
contents: read
steps:
- name: Check out code
- uses: actions/checkout@v7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
- name: Lint shell scripts
run: |
@@ -56,7 +81,9 @@ jobs:
contents: read
steps:
- name: Check out code
- uses: actions/checkout@v7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
- name: Lint Dockerfiles
run: docker run --rm -v "$PWD:/repo" -w /repo "hadolint/hadolint:${HADOLINT_VERSION}" hadolint src/variations/*/Dockerfile .oss-scanner/Dockerfile
@@ -69,7 +96,9 @@ jobs:
contents: read
steps:
- name: Check out code
- uses: actions/checkout@v7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
# Runs on a real runner on purpose: tool versions here (jq, yq) are what the
# publish workflows use, and they differ from laptops.
diff --git a/.github/workflows/service_publish-images.yml b/.github/workflows/service_publish-images.yml
index 1da0903e..ec83625a 100644
--- a/.github/workflows/service_publish-images.yml
+++ b/.github/workflows/service_publish-images.yml
@@ -3,7 +3,9 @@ name: Publish Images
# Phase 3 of 3: promote the exact images that passed the image tests from the Depot
# Registry to Docker Hub and GHCR. No rebuild happens here and no image bytes pass
# through the runner: `depot push` copies each saved build to every tag server-side,
-# and the published digest is checked against the one the build recorded. Depot
+# the published digest is checked against the one the build recorded, and each
+# registry's copy is signed with cosign. Signing is keyless: the signature is tied to
+# this workflow through its OIDC token, so there is no signing key to store or leak. Depot
# sponsors this project, so its runners are used for the phases that do real work.
on:
workflow_call:
@@ -16,6 +18,11 @@ on:
type: string
default: ${{ github.ref }}
description: 'The git ref to checkout (branch, tag, or commit SHA)'
+ secrets:
+ DOCKER_HUB_USERNAME:
+ required: true
+ DOCKER_HUB_ACCESS_TOKEN:
+ required: true
jobs:
list:
@@ -29,7 +36,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
@@ -63,29 +70,33 @@ 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
- name: Login to DockerHub
- uses: docker/login-action@v4
+ uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Login to GitHub Container Registry
- uses: docker/login-action@v4
+ uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
if: ${{ inputs.authenticate_with_ghcr }}
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Install cosign
+ uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
+
# `depot push` handles one tag at a time, so images are pushed in parallel and each
# one's output is printed afterwards as its own log group. Every image is attempted
# even after one fails, so a run shows every image that could not be published at once.
@@ -104,6 +115,21 @@ jobs:
PROJECT=$(jq -r .id depot.json)
mkdir -p image-details logs
+ retry() {
+ local description="$1" attempt=1
+ shift
+ until "$@"; do
+ if [ "$attempt" -ge "$PUSH_ATTEMPTS" ]; then
+ echo "::error title=Publish failed::${description} failed after ${PUSH_ATTEMPTS} attempts"
+ return 1
+ fi
+ local pause=$(( 15 * attempt + RANDOM % 15 ))
+ echo "${description}: attempt ${attempt} of ${PUSH_ATTEMPTS} failed, retrying in ${pause}s"
+ sleep "$pause"
+ attempt=$(( attempt + 1 ))
+ done
+ }
+
publish_image() {
local php="$1" os="$2" saved="$3" build_id="$4" digest="$5" tags="$6"
local image_name="${VARIATION}-${php}-${os}"
@@ -112,17 +138,7 @@ jobs:
tag_args+=(--tag "$tag")
done
echo "Pushing build $build_id to $(( ${#tag_args[@]} / 2 )) tags"
- local attempt=1
- until depot push --project "$PROJECT" --progress plain "${tag_args[@]}" "$build_id"; do
- if [ "$attempt" -ge "$PUSH_ATTEMPTS" ]; then
- echo "::error title=Publish failed::${image_name} could not be pushed after ${PUSH_ATTEMPTS} attempts"
- return 1
- fi
- local pause=$(( 15 * attempt + RANDOM % 15 ))
- echo "Push attempt ${attempt} of ${PUSH_ATTEMPTS} failed, retrying in ${pause}s"
- sleep "$pause"
- attempt=$(( attempt + 1 ))
- done
+ retry "Pushing ${image_name}" depot push --project "$PROJECT" --progress plain "${tag_args[@]}" "$build_id" || return 1
# What was published must be what was tested: the tested digest has to appear
# in the published index (as the index itself or one of its manifests).
@@ -138,6 +154,13 @@ jobs:
echo "Published digest matches the tested image ($digest)"
fi
+ # Every tag in a registry points at the same index, so one signature per registry covers them all.
+ local index_digest repository
+ index_digest=$(docker buildx imagetools inspect "$canonical_tag" --format '{{json .Manifest.Digest}}' | jq -r .)
+ for repository in $(tr ',' '\n' <<< "$tags" | sed 's/:[^:]*$//' | sort -u); do
+ retry "Signing ${repository}@${index_digest}" cosign sign --yes "${repository}@${index_digest}" || return 1
+ done
+
local manifest sizes
manifest=$(docker manifest inspect -v "$canonical_tag") || manifest='[]'
sizes=$(echo "$manifest" | jq '
@@ -187,7 +210,7 @@ jobs:
- name: Record the published images
if: always()
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: image-details-published-${{ matrix.variation }}
path: image-details/
diff --git a/.github/workflows/service_report.yml b/.github/workflows/service_report.yml
index f57fb233..0d6cd1e1 100644
--- a/.github/workflows/service_report.yml
+++ b/.github/workflows/service_report.yml
@@ -29,12 +29,13 @@ jobs:
markdown: ${{ steps.render.outputs.markdown }}
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 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
diff --git a/.github/workflows/service_scan-images.yml b/.github/workflows/service_scan-images.yml
new file mode 100644
index 00000000..89203bbf
--- /dev/null
+++ b/.github/workflows/service_scan-images.yml
@@ -0,0 +1,212 @@
+name: Scan Images
+
+# Runs after every production publish, or by hand for the latest one. Grype scans each
+# published image. Every finding is counted in the run summary, which anyone can read,
+# and the findings that have a fix become code scanning alerts in the Security tab.
+#
+# A compromised scanner has nothing to steal: the scan job has no token, no registry
+# credentials, and Grype runs in its own container without the runner's environment.
+# Images are pulled anonymously from GHCR, which has no pull limit for public images.
+# Only the alerts job can write, and it never runs Grype.
+on:
+ workflow_call:
+ inputs:
+ run-id:
+ type: string
+ default: ''
+ description: 'The production run whose published images to scan. Defaults to the calling run.'
+ workflow_dispatch:
+ inputs:
+ run-id:
+ type: string
+ required: false
+ description: 'The production run whose published images to scan. Left empty, the latest successful one.'
+
+permissions: {}
+
+env:
+ # renovate: datasource=docker depName=anchore/grype
+ GRYPE_VERSION: v0.121.0@sha256:621d56d1d68106b787022c81546f7e0a36202a25d1239f0c20195fab8c16f7ea
+ SCAN_REPOSITORY: ghcr.io/serversideup/php
+ SCAN_PLATFORM: linux/amd64
+
+jobs:
+ plan:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ permissions:
+ actions: read
+ contents: read
+ outputs:
+ matrix: ${{ steps.list.outputs.matrix }}
+ count: ${{ steps.list.outputs.count }}
+ main-sha: ${{ steps.run.outputs.main-sha }}
+ steps:
+ - name: Find the production run to scan
+ id: run
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ INPUT_RUN_ID: ${{ inputs.run-id }}
+ WORKFLOW_REF: ${{ github.workflow_ref }}
+ run: |
+ # Called from a production run, the run itself. Started by hand, the latest successful one.
+ RUN_ID="$INPUT_RUN_ID"
+ if [ -z "$RUN_ID" ] && [[ "$WORKFLOW_REF" == *"/service_scan-images.yml@"* ]]; then
+ RUN_ID=$(gh run list --workflow action_publish-images-production.yml --status success --limit 1 --json databaseId --jq '.[0].databaseId')
+ fi
+ echo "id=${RUN_ID:-$GITHUB_RUN_ID}" | tee -a "$GITHUB_OUTPUT"
+ # Alerts describe the published images, not a commit, so every scan reports
+ # against main. Release and weekly scans then share one list of alerts.
+ echo "main-sha=$(gh api "repos/${GH_REPO}/commits/main" --jq .sha)" | tee -a "$GITHUB_OUTPUT"
+
+ - name: Download the published image records
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
+ with:
+ pattern: image-details-published-*
+ path: image-details
+ merge-multiple: true
+ run-id: ${{ steps.run.outputs.id }}
+ github-token: ${{ github.token }}
+
+ - name: Group the images by variation
+ id: list
+ run: |
+ # shellcheck disable=SC2016 # $repository is a jq variable
+ MATRIX=$(find image-details -name '*.published.json' -print0 | xargs -0 -r jq -s -c --arg repository "$SCAN_REPOSITORY" '
+ map({image: "\(.php)-\(.variation)-\(.os)", variation, php, os, tag: "\($repository):\(.php)-\(.variation)-\(.os)"})
+ | group_by(.variation)
+ | { include: map({ variation: .[0].variation, images: tojson }) }')
+ MATRIX="${MATRIX:-{\"include\":[]\}}"
+ COUNT=$(echo "$MATRIX" | jq '[.include[].images | fromjson | length] | add // 0')
+ echo "matrix=$MATRIX" >> "$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 6b116ec8..cdaced6f 100644
--- a/.github/workflows/service_setup-matrix.yml
+++ b/.github/workflows/service_setup-matrix.yml
@@ -34,8 +34,9 @@ jobs:
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. 😎
@@ -46,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 '
@@ -80,13 +85,13 @@ jobs:
echo ""
echo "| PHP | Resolved patch version | Base OS |"
echo "|---|---|---|"
- yq -o=json '${{ inputs.php-versions-file }}' | jq -r '
+ yq -o=json "$PHP_VERSIONS_FILE" | jq -r '
.php_versions[].minor_versions[]
| "| \(.minor) | \(.patch_versions | join(", ")) | \(.base_os | map(.name) | join(", ")) |"'
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload the php-versions.yml file
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: php-versions.yml
path: ${{ inputs.php-versions-file }}
diff --git a/.github/workflows/service_test-images.yml b/.github/workflows/service_test-images.yml
index cd00ea31..bd2cd903 100644
--- a/.github/workflows/service_test-images.yml
+++ b/.github/workflows/service_test-images.yml
@@ -26,7 +26,7 @@ jobs:
count: ${{ steps.list.outputs.count }}
steps:
- name: Download image details from every build job
- uses: actions/download-artifact@v8
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: image-details-*
path: image-details
@@ -65,12 +65,13 @@ jobs:
matrix: ${{ fromJson(needs.list.outputs.matrix) }}
steps:
- name: Check out code
- uses: actions/checkout@v7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
+ persist-credentials: false
ref: ${{ inputs.ref }}
- name: Set up Depot CLI
- uses: depot/setup-action@v1
+ uses: depot/setup-action@91bc8495a33ebfc504ffc89e5674379ccf23c29c # v1.7.2
with:
oidc: true
@@ -83,10 +84,12 @@ jobs:
env:
IMAGES: ${{ matrix.images }}
PHP: ${{ matrix.php }}
+ OS: ${{ matrix.os }}
+ ARCH: ${{ matrix.arch }}
run: |
FAILED=()
while IFS=$'\t' read -r VARIATION SAVED; do
- echo "::group::${VARIATION} ${PHP}-${{ matrix.os }} ${{ matrix.arch }}"
+ echo "::group::${VARIATION} ${PHP}-${OS} ${ARCH}"
if ! { docker pull --quiet "$SAVED" && bash scripts/test-image.sh "$SAVED" "$PHP"; }; then
FAILED+=("$VARIATION")
fi
@@ -94,6 +97,6 @@ jobs:
done < <(echo "$IMAGES" | jq -r '.[] | [.variation, .saved] | @tsv')
if [ "${#FAILED[@]}" -gt 0 ]; then
- echo "::error title=Image tests failed::${FAILED[*]} failed for ${PHP}-${{ matrix.os }} on ${{ matrix.arch }}"
+ echo "::error title=Image tests failed::${FAILED[*]} failed for ${PHP}-${OS} on ${ARCH}"
exit 1
fi
diff --git a/.github/workflows/trigger_auto-retry-failed-builds.yml b/.github/workflows/trigger_auto-retry-failed-builds.yml
index 045e8dab..e82ed8cf 100644
--- a/.github/workflows/trigger_auto-retry-failed-builds.yml
+++ b/.github/workflows/trigger_auto-retry-failed-builds.yml
@@ -8,8 +8,11 @@ name: Auto-retry failed image builds
#
# 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)"
diff --git a/.github/zizmor.yml b/.github/zizmor.yml
new file mode 100644
index 00000000..d9628331
--- /dev/null
+++ b/.github/zizmor.yml
@@ -0,0 +1,6 @@
+# zizmor configuration for .github/workflows
+# Each disabled rule is a deliberate project decision, not an oversight.
+rules:
+ # Its fix, `uses: $/...`, is rejected by actionlint 1.7.12, which also lints these workflows.
+ self-repository:
+ disable: true
diff --git a/.oss-scanner/threat_model.md b/.oss-scanner/threat_model.md
index adf2719f..859b1740 100644
--- a/.oss-scanner/threat_model.md
+++ b/.oss-scanner/threat_model.md
@@ -6,6 +6,10 @@ serversideup/php publishes PHP Docker images (`cli`, `fpm`, `fpm-nginx`, `fpm-ap
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.
+- **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):
@@ -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 7ed41c0f..0095cd5e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -102,8 +102,11 @@ When modifying the version pipeline, the base config (`php-versions-base-config.
- 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-php-base.yml` compiles each PHP base (one job per PHP version, base OS, and `php_base`) and saves it to the Depot Registry as `
+
diff --git a/SECURITY.md b/SECURITY.md
index 4a011fee..eb4b0acd 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -19,7 +19,7 @@ Vulnerabilities in what *we* author and ship:
- Entrypoint scripts, healthchecks, helper scripts, and S6 service definitions in `src/`
- Default configs we ship for PHP-FPM, NGINX, Apache, and FrankenPHP
- Insecure defaults: ports, file permissions, the `www-data` user model, SSL defaults
-- Build pipeline integrity (GitHub Actions workflows, tag assembly, image publishing)
+- Build pipeline integrity (GitHub Actions workflows, tag assembly, image publishing, image signing)
- Documentation that materially misleads users about a security-relevant setting
## What is out of scope — report to the upstream project
@@ -34,7 +34,13 @@ Vulnerabilities in software we package but do not maintain belong with the upstr
| Composer | [composer/composer on GitHub](https://github.com/composer/composer/security) |
| Debian / Alpine base packages | The respective distribution security teams |
-If an upstream CVE is already publicly disclosed, you don't need to file with us — we pick up upstream patches on our **weekly rebuilds (Tuesday 08:00 UTC)** for floating tags. See [How our releases work](https://serversideup.net/open-source/docker-php/docs/getting-started/upgrade-guide#how-our-releases-work) for the full cadence and which tags receive rebuilds.
+If an upstream CVE is already publicly disclosed, you don't need to file with us. Floating tags pick up upstream fixes on their own: a new PHP patch publishes within about an hour of php.net listing it, and operating system packages update on the **weekly rebuild (Tuesday 08:00 UTC)**. See [How our releases work](https://serversideup.net/open-source/docker-php/docs/getting-started/upgrade-guide#how-our-releases-work) for the full cadence and which tags receive rebuilds.
+
+## Verifying images and public scan results
+
+Images we publish are signed with cosign and carry an SBOM and build provenance. Images published before signing was added have provenance only. [Verifying images](https://serversideup.net/open-source/docker-php/docs/guide/verifying-images) shows how to check them.
+
+Each production build ends with a vulnerability scan of every published image. The results are in the run summary of each [production run](https://github.com/serversideup/docker-php/actions/workflows/action_publish-images-production.yml), and [Vulnerability scanning](https://serversideup.net/open-source/docker-php/docs/reference/vulnerability-scanning) explains how to read them. Scanner findings are already disclosed upstream CVEs. Anything that is not yet public, including a problem in what we author, still goes through [private reporting](#reporting-a-vulnerability).
## Component lifecycle references
diff --git a/docs/content/docs/1.getting-started/7.upgrade-guide.md b/docs/content/docs/1.getting-started/7.upgrade-guide.md
index 7172b453..3fe2c126 100644
--- a/docs/content/docs/1.getting-started/7.upgrade-guide.md
+++ b/docs/content/docs/1.getting-started/7.upgrade-guide.md
@@ -20,7 +20,9 @@ Regardless if you are choosing to use floating tags or pinned releases, it is hi
All source code merges into the `main` branch. A pre-release is created for beta testing. After internal and community testing, we [publish a release on GitHub](https://github.com/serversideup/docker-php/releases) with a detailed changelog. Publishing a release triggers a production build that pushes a new set of tags to Docker Hub and GitHub Packages.
-Production images are also rebuilt **automatically every Tuesday at 08:00 UTC** so floating tags pick up the latest upstream PHP and operating system security patches even when we haven't cut a release that week.
+Production images are also rebuilt **automatically every Tuesday at 08:00 UTC** so floating tags pick up the latest operating system security patches even when we haven't cut a release that week.
+
+New PHP patches don't wait for Tuesday. Every hour, a workflow checks php.net for active PHP releases and starts a production build when a patch has no image yet, so a new patch is usually published within about an hour of php.net listing it, on any day. Like the weekly rebuild, these builds only update floating tags such as `8.5-cli` and `8.5.13-cli`. Version-pinned `-vX.Y.Z` tags are only created when we publish a GitHub release.
### Floating tags vs. version-pinned tags
diff --git a/docs/content/docs/1.getting-started/99.contributing.md b/docs/content/docs/1.getting-started/99.contributing.md
index ce6fe791..7fcc73ae 100644
--- a/docs/content/docs/1.getting-started/99.contributing.md
+++ b/docs/content/docs/1.getting-started/99.contributing.md
@@ -92,11 +92,13 @@ We use GitHub Actions exclusively to publish all of our releases. If the image e
See `.github/workflows/action_publish-images-beta.yml` for an example of how we publish our beta images.
### Linting
-Every pull request starts with a `lint` group that runs before any image is built: [actionlint](https://github.com/rhysd/actionlint) for the workflows, [ShellCheck](https://www.shellcheck.net/) for the scripts, and [hadolint](https://github.com/hadolint/hadolint) for the Dockerfiles, plus fixture tests for the helper scripts in `scripts/` that CI relies on. Run the same checks locally before you push:
+Every pull request starts with a `lint` group that runs before any image is built: [actionlint](https://github.com/rhysd/actionlint) and [zizmor](https://docs.zizmor.sh/) for the workflows, [ShellCheck](https://www.shellcheck.net/) for the scripts, and [hadolint](https://github.com/hadolint/hadolint) for the Dockerfiles, plus fixture tests for the helper scripts in `scripts/` that CI relies on. Run the same checks locally before you push:
```bash [Terminal]
docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest -color
+docker run --rm -v "$PWD:/repo:ro" -w /repo ghcr.io/zizmorcore/zizmor:latest .github/workflows
+
docker run --rm -v "$PWD:/repo" -w /repo koalaman/shellcheck:stable --severity=warning \
scripts/*.sh scripts/tests/*.sh scripts/tests/image/*.sh src/common/usr/local/bin/* src/s6/usr/local/bin/* .oss-scanner/run-variation $(find src -path '*entrypoint.d*' -name '*.sh')
@@ -107,6 +109,8 @@ bash scripts/tests/run.sh
The script tests live in one file, `scripts/tests/run.sh`, as plain assertions over a small set of sample images. CI runs the same file on a GitHub runner, whose `jq` and `yq` versions can differ from your machine, so a green run there is the one that counts.
+zizmor audits the workflows for security problems such as actions that aren't pinned to a commit SHA, template injection, and broad token permissions. Every action is pinned to a full commit SHA with its version in a comment, such as `actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1`, and Renovate keeps the pins current. Pin any action you add the same way. In CI, zizmor also gets a read-only token so it can check that each pinned SHA belongs to its repository. To run that check locally, set `GH_TOKEN` and add `-e GH_TOKEN` to the command. When you disable a zizmor rule, record it and the reason in `.github/zizmor.yml`.
+
The Depot runner labels we use are listed in `.github/actionlint.yaml` so actionlint recognizes them. New linters belong in `.github/workflows/service_lint.yml`.
### Image tests
diff --git a/docs/content/docs/5.guide/7.verifying-images.md b/docs/content/docs/5.guide/7.verifying-images.md
new file mode 100644
index 00000000..5288947b
--- /dev/null
+++ b/docs/content/docs/5.guide/7.verifying-images.md
@@ -0,0 +1,76 @@
+---
+head.title: 'Verifying Images - Docker PHP - Server Side Up'
+description: 'Check that a serversideup/php image was built and published by our GitHub Actions workflows, and see what is inside it.'
+layout: docs
+title: Verifying images
+---
+
+::lead-p
+Every image we publish is signed and carries a software bill of materials (SBOM) and build provenance. Use them to confirm that the image you pulled came from our GitHub Actions workflows and to see exactly what went into it.
+::
+
+## Why verify an image
+A tag is only a pointer. Whoever controls it can point `8.5-cli` at a different image. A signature check proves the image was published by the `serversideup/docker-php` repository's own publish workflow, from `main` or a release tag. The SBOM and provenance tell you what is in the image and how it was built, which is what most supply-chain policies and audits ask for.
+
+::note
+Images published before we started signing have provenance but no SBOM or signature. Version-pinned tags such as `8.4-fpm-nginx-v4.3.5` are never rebuilt. Older ones will always fail the signature check. Floating tags like `8.5-cli` are signed from their next rebuild on.
+::
+
+## Verify the signature
+Install cosign by following the [Sigstore installation guide](https://docs.sigstore.dev/cosign/system_config/installation/), then run:
+
+```bash [Terminal]
+cosign verify serversideup/php:8.5-cli \
+ --certificate-oidc-issuer https://token.actions.githubusercontent.com \
+ --certificate-identity-regexp '^https://github\.com/serversideup/docker-php/\.github/workflows/service_publish-images\.yml@refs/(heads/main|tags/v.+)$'
+```
+
+The two flags say who you trust. The issuer is GitHub Actions. The identity is our publish workflow running from `main` or a `v` release tag. cosign exits with an error if the image has no signature from that identity.
+
+Signing is keyless: there is no public key to download. Each signature is tied to the workflow run that made it through GitHub's OIDC token and recorded in Sigstore's public transparency log.
+
+Docker Hub and GitHub Packages each hold their own signature. The same command works for `ghcr.io/serversideup/php:8.5-cli`. The signature covers the image index, which references the image for every platform along with its SBOM and provenance. One check covers all of them.
+
+## Read the SBOM
+The SBOM is an [SPDX](https://spdx.dev/) document that lists the packages in the image. Read it for one platform with Docker Buildx:
+
+```bash [Terminal]
+docker buildx imagetools inspect serversideup/php:8.5-cli \
+ --format '{{ json (index .SBOM "linux/amd64").SPDX }}'
+```
+
+Use `linux/arm64` for the arm64 image. On an image published before SBOMs were added, the command prints `null`.
+
+## Read the build provenance
+The provenance is a [SLSA](https://slsa.dev/) record of how the image was built: the source commit, the Dockerfile, the build arguments, and the base images. We publish it in full (`mode=max`):
+
+```bash [Terminal]
+docker buildx imagetools inspect serversideup/php:8.5-cli \
+ --format '{{ json (index .Provenance "linux/amd64").SLSA }}'
+```
+
+To see only the commit and Dockerfile an image was built from, read `invocation.configSource`:
+
+```bash [Terminal]
+docker buildx imagetools inspect serversideup/php:8.5-cli \
+ --format '{{ json (index .Provenance "linux/amd64").SLSA.invocation.configSource }}'
+```
+
+```json [Output]
+{
+ "digest": {
+ "sha1": "af1462e3abf8c67ff3fb78141255adc33de5bf2c"
+ },
+ "entryPoint": "src/variations/cli/Dockerfile",
+ "uri": "https://github.com/serversideup/docker-php.git#af1462e3abf8c67ff3fb78141255adc33de5bf2c"
+}
+```
+
+## Scan it yourself
+You don't have to trust our scan. The images are public and work with any scanner. For example, with [Grype](https://github.com/anchore/grype):
+
+```bash [Terminal]
+grype serversideup/php:8.5-cli
+```
+
+Add `--only-fixed` to list only the findings that have a fix available. Those are the ones a rebuild or an upgrade can change. See [fixable and unfixed findings](/docs/reference/vulnerability-scanning#most-findings-have-no-fix-yet) for why the full list is usually much longer.
diff --git a/docs/content/docs/8.reference/3.vulnerability-scanning.md b/docs/content/docs/8.reference/3.vulnerability-scanning.md
new file mode 100644
index 00000000..a1cd255b
--- /dev/null
+++ b/docs/content/docs/8.reference/3.vulnerability-scanning.md
@@ -0,0 +1,34 @@
+---
+head.title: 'Vulnerability Scanning - Docker PHP - Server Side Up'
+description: 'How every published serversideup/php image is scanned for known vulnerabilities, and where to see the results.'
+layout: docs
+title: Vulnerability scanning
+---
+
+::lead-p
+Every image we publish is scanned for known vulnerabilities after each production build. The results are public, and the numbers to watch are the fixable ones.
+::
+
+## When and how images are scanned
+A scan runs after every production publish. That covers each release, the Tuesday 08:00 UTC weekly rebuild, and the runs that ship new PHP patches. [Grype](https://github.com/anchore/grype) scans the `linux/amd64` image of every published tag, pulled from GitHub Packages.
+
+The scan job has no access token and no registry credentials, and Grype runs in its own container. A compromised scanner would have nothing to steal and nothing it could publish.
+
+## See the latest results
+Open the [latest successful production run](https://github.com/serversideup/docker-php/actions/workflows/action_publish-images-production.yml?query=is%3Asuccess). Its summary page has a table for each variation with every image's critical, high, and medium counts, and how many findings have a fix.
+
+Findings that have a fix also become code scanning alerts for the maintainers. Each one closes once a scan no longer finds it.
+
+## Most findings have no fix yet
+A scanner matches every package in the image against public CVE databases. Many of the CVEs it reports are ones the distribution has marked "not fixed" or "won't fix", so there is no newer package to install. A scan of `8.5-cli-bookworm` found 18 critical and 144 high CVEs, and none of them had a fix.
+
+No rebuild can change an unfixed finding. The fixable count is the meaningful number. The docker-library project explains this in more depth in [Why does my security scanner show that an image has CVEs?](https://github.com/docker-library/faq#why-does-my-security-scanner-show-that-an-image-has-cves)
+
+## Scan it yourself
+You don't have to rely on our scan. The images are public and work with any scanner:
+
+```bash [Terminal]
+grype serversideup/php:8.5-cli --only-fixed
+```
+
+To report a problem in our own scripts, configs, or workflows, use [private vulnerability reporting](https://github.com/serversideup/docker-php/security/policy) instead.
diff --git a/renovate.json b/renovate.json
index 01a33d7e..d1cb568f 100644
--- a/renovate.json
+++ b/renovate.json
@@ -1,14 +1,14 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
- "extends": ["config:recommended"],
+ "extends": ["config:recommended", "helpers:pinGitHubActionDigests"],
"enabledManagers": ["custom.regex", "github-actions", "npm"],
"customManagers": [
{
"customType": "regex",
"description": "Versions with a '# renovate:' comment on the line above",
- "managerFilePatterns": ["scripts/conf/php-versions-base-config.yml", ".github/workflows/service_lint.yml"],
+ "managerFilePatterns": ["scripts/conf/php-versions-base-config.yml", ".github/workflows/service_lint.yml", ".github/workflows/service_scan-images.yml"],
"matchStrings": [
- "#\\s*renovate:\\s*datasource=(?