Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 119 additions & 54 deletions .github/scripts/docker-compute-fingerprints.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,69 +17,134 @@ get_php_version() {
jq -r '.Env[] | select(test("^PHP_VERSION=")) | sub("^PHP_VERSION="; "")'
}

PHP_82_LATEST="$(get_php_version 8.2)"
PHP_83_LATEST="$(get_php_version 8.3)"
PHP_84_LATEST="$(get_php_version 8.4)"
PHP_85_LATEST="$(get_php_version 8.5)"

PHP_VERSION="${PHP_82_LATEST},${PHP_83_LATEST},${PHP_84_LATEST},${PHP_85_LATEST}"
write_output "php_version=${PHP_VERSION}"
write_output "php82_version=${PHP_82_LATEST//./-}"
write_output "php83_version=${PHP_83_LATEST//./-}"
write_output "php84_version=${PHP_84_LATEST//./-}"
write_output "php85_version=${PHP_85_LATEST//./-}"

if [[ "${GITHUB_EVENT_NAME:-}" == "schedule" ]]; then
FRANKENPHP_LATEST_TAG="$(gh release view --repo php/frankenphp --json tagName --jq '.tagName')"
git checkout "${FRANKENPHP_LATEST_TAG}"
fi

METADATA="$(PHP_VERSION="${PHP_VERSION}" docker buildx bake --print | jq -c)"

BASE_IMAGES=()
while IFS= read -r image; do
BASE_IMAGES+=("${image}")
done < <(jq -r '
.target[]?.contexts? | to_entries[]?
| select(.value | startswith("docker-image://"))
| .value
| sub("^docker-image://"; "")
' <<<"${METADATA}" | sort -u)

BASE_IMAGE_DIGESTS=()
for image in "${BASE_IMAGES[@]}"; do
get_image_digest() {
local image="$1" ref
if [[ "${image}" == */* ]]; then
ref="docker://docker.io/${image}"
else
ref="docker://docker.io/library/${image}"
fi
digest="$(skopeo inspect "${ref}" \
skopeo inspect "${ref}" \
--override-os linux \
--override-arch amd64 \
--format '{{.Digest}}')"
BASE_IMAGE_DIGESTS+=("${image}@${digest}")
done
--format '{{.Digest}}'
}

BASE_FINGERPRINT="$(printf '%s\n' "${BASE_IMAGE_DIGESTS[@]}" | sort | sha256sum | awk '{print $1}')"
write_output "base_fingerprint=${BASE_FINGERPRINT}"
get_existing_fingerprint() {
local tag="$1"
skopeo inspect "docker://docker.io/dunglas/frankenphp:${tag}" \
--override-os linux \
--override-arch amd64 2>/dev/null |
jq -r '.Labels["dev.frankenphp.base.fingerprint"] // empty' || true
}

if [[ "${GITHUB_EVENT_NAME:-}" != "schedule" ]]; then
write_output "skip=false"
exit 0
fi
# Everything runs from main(), parsed before execution starts: this script checks out
# another Git reference (and thus replaces itself on disk) while running, and bash
# reads script files lazily.
main() {
local PHP_82_LATEST PHP_83_LATEST PHP_84_LATEST PHP_85_LATEST PHP_VERSION
PHP_82_LATEST="$(get_php_version 8.2)"
PHP_83_LATEST="$(get_php_version 8.3)"
PHP_84_LATEST="$(get_php_version 8.4)"
PHP_85_LATEST="$(get_php_version 8.5)"

FRANKENPHP_LATEST_TAG_NO_PREFIX="${FRANKENPHP_LATEST_TAG#v}"
EXISTING_FINGERPRINT=$(
skopeo inspect "docker://docker.io/dunglas/frankenphp:${FRANKENPHP_LATEST_TAG_NO_PREFIX}" \
--override-os linux \
--override-arch amd64 |
jq -r '.Labels["dev.frankenphp.base.fingerprint"] // empty'
)
PHP_VERSION="${PHP_82_LATEST},${PHP_83_LATEST},${PHP_84_LATEST},${PHP_85_LATEST}"
write_output "php_version=${PHP_VERSION}"
write_output "php82_version=${PHP_82_LATEST//./-}"
write_output "php83_version=${PHP_83_LATEST//./-}"
write_output "php84_version=${PHP_84_LATEST//./-}"
write_output "php85_version=${PHP_85_LATEST//./-}"

local FRANKENPHP_LATEST_TAG=""
if [[ "${GITHUB_EVENT_NAME:-}" == "schedule" ]]; then
FRANKENPHP_LATEST_TAG="$(gh release view --repo php/frankenphp --json tagName --jq '.tagName')"
git checkout "${FRANKENPHP_LATEST_TAG}"
write_output "ref=${FRANKENPHP_LATEST_TAG}"
fi

# Pin the revision and timestamps to the commit being built so that rebuilding
# unchanged sources produces reproducible images
write_output "sha=$(git rev-parse HEAD)"
write_output "source_date_epoch=$(git log -1 --format=%ct)"
write_output "created=$(TZ=UTC git log -1 --date=format-local:%Y-%m-%dT%H:%M:%SZ --format=%cd)"

local METADATA
METADATA="$(PHP_VERSION="${PHP_VERSION}" docker buildx bake --print | jq -c)"

if [[ -n "${EXISTING_FINGERPRINT}" ]] && [[ "${EXISTING_FINGERPRINT}" == "${BASE_FINGERPRINT}" ]]; then
write_output "skip=true"
exit 0
fi
# Collect the base images (docker-image:// contexts) of each variant. The variant key
# is derived from the php-base ref (e.g. "php:8.4.23-zts-trixie" -> "8.4.23-trixie") and
# matches the "${php-version}-${os}" keys expected by docker-bake.hcl for BASE_FINGERPRINTS.
declare -A VARIANT_IMAGES=() VARIANT_NAMES=() DIGEST_CACHE=() VARIANT_FINGERPRINTS=()
local target php_base images image variant_key variant_name
while IFS=$'\t' read -r target php_base images; do
[[ -z "${php_base}" ]] && continue
variant_key="${php_base#php:}"
variant_key="${variant_key/-zts/}"
variant_name="${target#builder-}"
variant_name="${variant_name#runner-}"
VARIANT_IMAGES["${variant_key}"]="${images}"
VARIANT_NAMES["${variant_key}"]="${variant_name}"
done < <(jq -r '
.target | to_entries[]
| (.value.contexts // {} | [to_entries[].value | select(startswith("docker-image://")) | sub("^docker-image://"; "")]) as $images
| select(($images | length) > 0)
| [.key, ($images | map(select(startswith("php:"))) | first // ""), ($images | sort | join(" "))]
| @tsv
' <<<"${METADATA}")

# Fingerprint each variant from the digests of its own base images, and keep the
# legacy fingerprint of all base images for images built before the per-variant split
local fingerprints_json="{}" all_digests=()
for variant_key in "${!VARIANT_IMAGES[@]}"; do
local variant_digests=()
for image in ${VARIANT_IMAGES["${variant_key}"]}; do
if [[ -z "${DIGEST_CACHE["${image}"]:-}" ]]; then
DIGEST_CACHE["${image}"]="$(get_image_digest "${image}")"
fi
variant_digests+=("${image}@${DIGEST_CACHE["${image}"]}")
done
VARIANT_FINGERPRINTS["${variant_key}"]="$(printf '%s\n' "${variant_digests[@]}" | sort | sha256sum | awk '{print $1}')"
fingerprints_json="$(jq -c --arg k "${variant_key}" --arg v "${VARIANT_FINGERPRINTS["${variant_key}"]}" '. + {($k): $v}' <<<"${fingerprints_json}")"
done
for image in "${!DIGEST_CACHE[@]}"; do
all_digests+=("${image}@${DIGEST_CACHE["${image}"]}")
done
local BASE_FINGERPRINT
BASE_FINGERPRINT="$(printf '%s\n' "${all_digests[@]}" | sort | sha256sum | awk '{print $1}')"
write_output "base_fingerprint=${BASE_FINGERPRINT}"
write_output "base_fingerprints=${fingerprints_json}"

if [[ "${GITHUB_EVENT_NAME:-}" != "schedule" ]]; then
write_output "skip=false"
return 0
fi

# Only rebuild the variants whose base images changed since the last push. The
# fingerprint label is read from the versioned builder tag; images built before the
# per-variant split carry the legacy all-variants fingerprint, so accept it too.
local version_no_prefix="${FRANKENPHP_LATEST_TAG#v}"
local rebuild_variants=() php_full php_minor os builder_tag existing
for variant_key in "${!VARIANT_FINGERPRINTS[@]}"; do
php_full="${variant_key%-*}"
php_minor="${php_full%.*}"
os="${variant_key##*-}"
builder_tag="${version_no_prefix}-builder-php${php_minor}-${os}"
existing="$(get_existing_fingerprint "${builder_tag}")"
if [[ -n "${existing}" ]] &&
{ [[ "${existing}" == "${VARIANT_FINGERPRINTS["${variant_key}"]}" ]] || [[ "${existing}" == "${BASE_FINGERPRINT}" ]]; }; then
continue
fi
rebuild_variants+=("${VARIANT_NAMES["${variant_key}"]}")
done

if [[ ${#rebuild_variants[@]} -eq 0 ]]; then
write_output "rebuild_variants=[]"
write_output "skip=true"
return 0
fi

write_output "rebuild_variants=$(printf '%s\n' "${rebuild_variants[@]}" | sort -u | jq -R . | jq -cs .)"
write_output "skip=false"
}

write_output "ref=${FRANKENPHP_LATEST_TAG}"
write_output "skip=false"
main "$@"
30 changes: 26 additions & 4 deletions .github/workflows/docker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,26 @@ jobs:
php85_version: ${{ steps.check.outputs.php85_version }}
skip: ${{ steps.check.outputs.skip }}
ref: ${{ steps.check.outputs.ref || (github.ref_type == 'tag' && github.ref_name) || (github.event_name == 'workflow_dispatch' && inputs.version) || '' }}
sha: ${{ steps.check.outputs.sha }}
source_date_epoch: ${{ steps.check.outputs.source_date_epoch }}
created: ${{ steps.check.outputs.created }}
base_fingerprint: ${{ steps.check.outputs.base_fingerprint }}
base_fingerprints: ${{ steps.check.outputs.base_fingerprints }}
rebuild_variants: ${{ steps.check.outputs.rebuild_variants }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Check PHP versions and base image fingerprint
- name: Login to DockerHub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} # zizmor: ignore[secrets-outside-env] TODO: drop once super-linter ships zizmor >=1.24.0, then the allowlist in zizmor.yaml takes over
- name: Check PHP versions and base image fingerprints
id: check
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand All @@ -70,15 +81,21 @@ jobs:
run: |
set -e
METADATA="$(docker buildx bake --print | jq -c)"
variants="$(jq -c '.group.default.targets|map(sub("runner-|builder-"; ""))|unique' <<< "${METADATA}")"
# On scheduled rebuilds, only build the variants whose base images changed
if [[ -n "${REBUILD_VARIANTS}" && "${REBUILD_VARIANTS}" != "[]" ]]; then
variants="$(jq -c --argjson rebuild "${REBUILD_VARIANTS}" 'map(select(. as $v | $rebuild | index($v)))' <<< "${variants}")"
fi
{
echo metadata="${METADATA}"
echo variants="$(jq -c '.group.default.targets|map(sub("runner-|builder-"; ""))|unique' <<< "${METADATA}")"
echo variants="${variants}"
echo platforms="$(jq -c 'first(.target[]) | .platforms' <<< "${METADATA}")"
} >> "${GITHUB_OUTPUT}"
env:
SHA: ${{ github.sha }}
VERSION: ${{ (github.ref_type == 'tag' && github.ref_name) || steps.check.outputs.ref || 'dev' }}
PHP_VERSION: ${{ steps.check.outputs.php_version }}
REBUILD_VARIANTS: ${{ steps.check.outputs.rebuild_variants }}
build:
runs-on: ${{ startsWith(matrix.platform, 'linux/arm') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
needs:
Expand Down Expand Up @@ -142,21 +159,26 @@ jobs:
builder-${{ matrix.variant }}
runner-${{ matrix.variant }}
# Remove tags to prevent "can't push tagged ref [...] by digest" error
# Provenance attestations embed build timestamps, which would prevent reproducible images
set: |
*.tags=
*.platform=${{ matrix.platform }}
*.attest=type=provenance,disabled=true
${{ fromJson(needs.prepare.outputs.push) && '' || format('builder-{0}.cache-from=type=gha,scope=builder-{0}-{1}-{2}', matrix.variant, needs.prepare.outputs.ref || github.ref, matrix.platform) }}
${{ fromJson(needs.prepare.outputs.push) && '' || format('builder-{0}.cache-from=type=gha,scope=refs/heads/main-builder-{0}-{1}', matrix.variant, matrix.platform) }}
${{ fromJson(needs.prepare.outputs.push) && '' || format('builder-{0}.cache-to=type=gha,scope=builder-{0}-{1}-{2},ignore-error=true', matrix.variant, needs.prepare.outputs.ref || github.ref, matrix.platform) }}
${{ fromJson(needs.prepare.outputs.push) && '' || format('runner-{0}.cache-from=type=gha,scope=runner-{0}-{1}-{2}', matrix.variant, needs.prepare.outputs.ref || github.ref, matrix.platform) }}
${{ fromJson(needs.prepare.outputs.push) && '' || format('runner-{0}.cache-from=type=gha,scope=refs/heads/main-runner-{0}-{1}', matrix.variant, matrix.platform) }}
${{ fromJson(needs.prepare.outputs.push) && '' || format('runner-{0}.cache-to=type=gha,scope=runner-{0}-{1}-{2},ignore-error=true', matrix.variant, needs.prepare.outputs.ref || github.ref, matrix.platform) }}
${{ fromJson(needs.prepare.outputs.push) && format('*.output=type=image,name={0},push-by-digest=true,name-canonical=true,push=true', env.IMAGE_NAME) || '' }}
${{ fromJson(needs.prepare.outputs.push) && format('*.output=type=image,name={0},push-by-digest=true,name-canonical=true,push=true,rewrite-timestamp=true', env.IMAGE_NAME) || '' }}
env:
SHA: ${{ github.sha }}
SHA: ${{ needs.prepare.outputs.sha || github.sha }}
VERSION: ${{ (github.ref_type == 'tag' && github.ref_name) || needs.prepare.outputs.ref || 'dev' }}
PHP_VERSION: ${{ needs.prepare.outputs.php_version }}
BASE_FINGERPRINT: ${{ needs.prepare.outputs.base_fingerprint }}
BASE_FINGERPRINTS: ${{ needs.prepare.outputs.base_fingerprints }}
CREATED: ${{ needs.prepare.outputs.created }}
SOURCE_DATE_EPOCH: ${{ needs.prepare.outputs.source_date_epoch }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- # Workaround for https://github.com/actions/runner/pull/2477#issuecomment-1501003600
name: Export metadata
Expand Down
7 changes: 5 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ RUN apt-get update && \
libcap2-bin \
&& \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Remove files with non-deterministic content (embedded timestamps and mtimes) to keep image digests reproducible
rm -rf /var/lib/apt/lists/* /var/log/apt /var/log/dpkg.log /var/log/alternatives.log /var/cache/ldconfig/aux-cache
Comment on lines +15 to +16

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume you've made sure that this actually leads to reproducible image digests for base files. Otherwise everything looks correct.


RUN set -eux; \
mkdir -p \
Expand Down Expand Up @@ -139,7 +140,9 @@ COPY --from=builder /usr/local/lib/libwatcher* /usr/local/lib/
# fix for the file watcher on arm
RUN apt-get install -y --no-install-recommends libstdc++6 && \
apt-get clean && \
ldconfig
ldconfig && \
# Remove files with non-deterministic content (embedded timestamps and mtimes) to keep image digests reproducible
rm -rf /var/log/apt /var/log/dpkg.log /var/log/alternatives.log /var/cache/ldconfig/aux-cache

COPY --from=builder /usr/local/bin/frankenphp /usr/local/bin/frankenphp
RUN setcap cap_net_bind_service=+ep /usr/local/bin/frankenphp && \
Expand Down
34 changes: 26 additions & 8 deletions docker-bake.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ variable "BASE_FINGERPRINT" {
default = ""
}

# JSON map of "<php-version>-<os>" (e.g. "8.4.23-trixie") to the fingerprint of this variant's base images
variable "BASE_FINGERPRINTS" {
default = "{}"
}

# RFC 3339 date used for the org.opencontainers.image.created label, defaults to the build time.
# Set it to the commit date to keep image digests reproducible.
variable "CREATED" {
default = ""
}

variable "SPC_OPT_BUILD_ARGS" {
default = ""
}
Expand Down Expand Up @@ -120,12 +131,19 @@ target "default" {
VERSION == "dev" ? [] : [for v in semver(VERSION) : tag(v, os, pv, tgt)]
])
]))
labels = {
"org.opencontainers.image.created" = "${timestamp()}"
"org.opencontainers.image.version" = VERSION
"org.opencontainers.image.revision" = SHA
"dev.frankenphp.base.fingerprint" = BASE_FINGERPRINT
}
labels = merge(
{
"org.opencontainers.image.created" = CREATED != "" ? CREATED : timestamp()
"org.opencontainers.image.version" = VERSION
"org.opencontainers.image.revision" = SHA
},
# The base image fingerprint is only stored on builder images: both targets are always
# built and pushed together, and keeping it out of runner images makes their digests
# depend on their actual content only (a fingerprint change alone won't move them)
tgt == "builder" ? {
"dev.frankenphp.base.fingerprint" = contains(keys(jsondecode(BASE_FINGERPRINTS)), "${php-version}-${os}") ? jsondecode(BASE_FINGERPRINTS)["${php-version}-${os}"] : BASE_FINGERPRINT
} : {}
)
args = {
FRANKENPHP_VERSION = VERSION
}
Expand All @@ -148,7 +166,7 @@ target "static-builder-musl" {
VERSION == "dev" ? [] : [for v in semver(VERSION) : "${IMAGE_NAME}:static-builder-musl-${v}"]
]))
labels = {
"org.opencontainers.image.created" = "${timestamp()}"
"org.opencontainers.image.created" = CREATED != "" ? CREATED : timestamp()
"org.opencontainers.image.version" = VERSION
"org.opencontainers.image.revision" = SHA
"dev.frankenphp.base.fingerprint" = BASE_FINGERPRINT
Expand All @@ -174,7 +192,7 @@ target "static-builder-gnu" {
VERSION == "dev" ? [] : [for v in semver(VERSION) : "${IMAGE_NAME}:static-builder-gnu-${v}"]
]))
labels = {
"org.opencontainers.image.created" = "${timestamp()}"
"org.opencontainers.image.created" = CREATED != "" ? CREATED : timestamp()
"org.opencontainers.image.version" = VERSION
"org.opencontainers.image.revision" = SHA
"dev.frankenphp.base.fingerprint" = BASE_FINGERPRINT
Expand Down
Loading