diff --git a/.github/scripts/docker-compute-fingerprints.sh b/.github/scripts/docker-compute-fingerprints.sh index 709b45e55a..259d01b993 100755 --- a/.github/scripts/docker-compute-fingerprints.sh +++ b/.github/scripts/docker-compute-fingerprints.sh @@ -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 "$@" diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 28eb9b0a59..98709e343d 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -50,7 +50,12 @@ 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: @@ -58,7 +63,13 @@ jobs: 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 }} @@ -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: @@ -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 diff --git a/Dockerfile b/Dockerfile index 3465e83eab..6697209d38 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 RUN set -eux; \ mkdir -p \ @@ -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 && \ diff --git a/docker-bake.hcl b/docker-bake.hcl index 0d7cc1656f..7826903d16 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -18,6 +18,17 @@ variable "BASE_FINGERPRINT" { default = "" } +# JSON map of "-" (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 = "" } @@ -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 } @@ -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 @@ -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