Skip to content

Merge pull request #18 from netresearch/fix/build-runtime-target #34

Merge pull request #18 from netresearch/fix/build-runtime-target

Merge pull request #18 from netresearch/fix/build-runtime-target #34

Workflow file for this run

# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Netresearch DTT GmbH
#
# The daily rebuild is the whole point of this repository: upstream refreshes
# its image when phpMyAdmin is released, we refresh ours when its base picks up
# fixes. Two variants per run — `pinned` ships the libraries as released,
# `rolling` refreshes them within phpMyAdmin's own constraints.
name: build
on:
schedule:
- cron: '0 3 * * *' # 03:00 UTC, before the workday
push:
branches: [main]
paths-ignore:
- 'docs/**'
- 'examples/**'
- '**.md'
pull_request:
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/phpmyadmin-php-fpm
# nginx with the configuration and document root baked in, so a consumer
# needs two containers and no mounted files. Always deployed at the same
# tag as IMAGE_NAME — see the note on the web stage in the Dockerfile.
WEB_IMAGE_NAME: ${{ github.repository_owner }}/phpmyadmin-nginx
permissions: {}
concurrency:
group: build-${{ github.ref }}
# A scheduled multi-arch rebuild must not be interrupted half-pushed.
cancel-in-progress: ${{ github.event_name != 'schedule' }}
jobs:
build:
name: ${{ matrix.variant }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
include:
- variant: pinned
rolling: "false"
suffix: ""
- variant: rolling
rolling: "true"
suffix: "-rolling"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Read the pinned phpMyAdmin release
id: version
run: echo "pma=$(cat .phpmyadmin-version)" >> "$GITHUB_OUTPUT"
- uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
# Pushing only happens from main and the schedule: a pull request must be
# able to prove the image still builds without being able to publish it.
- name: Log in to the registry
if: github.event_name != 'pull_request'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Tags and labels
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
suffix=${{ matrix.suffix }},onlatest=true
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=${{ steps.version.outputs.pma }},enable={{is_default_branch}}
type=raw,value=${{ steps.version.outputs.pma }}-{{date 'YYYYMMDD'}},enable={{is_default_branch}}
type=ref,event=pr
- name: Build and push
id: push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
# Named explicitly. Without it Docker builds the LAST stage in the
# Dockerfile, and adding the web stage after runtime silently turned
# this build into the nginx one - php-fpm:latest was published as
# nginx until the smoke test on main caught it on /bin/bash.
target: runtime
# A pull request builds one architecture and keeps it local: `--load`
# cannot import a manifest list, so asking for both there fails with
# "docker exporter does not currently support exporting manifest
# lists" before a single layer is built.
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
push: ${{ github.event_name != 'pull_request' }}
load: ${{ github.event_name == 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.variant }}
cache-to: type=gha,mode=max,scope=${{ matrix.variant }}
build-args: |
PMA_VERSION=${{ steps.version.outputs.pma }}
ROLLING_DEPS=${{ matrix.rolling }}
BUILD_DATE=${{ github.event.repository.updated_at }}
VCS_REF=${{ github.sha }}
# The SBOM and provenance attestations travel with the manifest, so
# a consumer can ask the registry what is inside the image it pulled.
# Both need an exporter that writes a manifest, which the local
# `--load` on pull requests is not.
sbom: ${{ github.event_name != 'pull_request' }}
provenance: ${{ github.event_name != 'pull_request' && 'mode=max' || 'false' }}
# The smoke test would have caught a swapped image, but it is a separate
# job and the pull-request build never leaves this runner, so it only
# runs on main - which is where the wrong image had already been
# published. This asserts the identity of what was just built, in the
# job that builds it, where a missing `target:` actually goes wrong.
- name: The image is php-fpm, not the web stage
if: github.event_name == 'pull_request'
env:
TAGS: ${{ steps.meta.outputs.tags }}
run: |
set -euo pipefail
tag="$(printf '%s\n' "$TAGS" | head -n1)"
docker run --rm --entrypoint sh "$tag" -c \
'command -v php-fpm >/dev/null && test -x /bin/bash && ! command -v nginx >/dev/null'
- name: Attest the build
if: github.event_name != 'pull_request'
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
# The web image is built here rather than in a job of its own so that
# both images come out of one run with one tag set. They share the
# document root, and deploying them at different tags means running two
# phpMyAdmin versions against each other.
- name: Tags and labels (web)
id: meta-web
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.REGISTRY }}/${{ env.WEB_IMAGE_NAME }}
flavor: |
suffix=${{ matrix.suffix }},onlatest=true
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=${{ steps.version.outputs.pma }},enable={{is_default_branch}}
type=raw,value=${{ steps.version.outputs.pma }}-{{date 'YYYYMMDD'}},enable={{is_default_branch}}
type=ref,event=pr
- name: Build and push (web)
id: push-web
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
target: web
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
push: ${{ github.event_name != 'pull_request' }}
load: ${{ github.event_name == 'pull_request' }}
tags: ${{ steps.meta-web.outputs.tags }}
labels: ${{ steps.meta-web.outputs.labels }}
# Same cache scope as the runtime build: both share the fetcher
# stage, so the second build reuses it instead of downloading and
# verifying the release a second time.
cache-from: type=gha,scope=${{ matrix.variant }}
cache-to: type=gha,mode=max,scope=${{ matrix.variant }}
build-args: |
PMA_VERSION=${{ steps.version.outputs.pma }}
ROLLING_DEPS=${{ matrix.rolling }}
BUILD_DATE=${{ github.event.repository.updated_at }}
VCS_REF=${{ github.sha }}
sbom: ${{ github.event_name != 'pull_request' }}
provenance: ${{ github.event_name != 'pull_request' && 'mode=max' || 'false' }}
- name: The web image is nginx, not the runtime stage
if: github.event_name == 'pull_request'
env:
TAGS: ${{ steps.meta-web.outputs.tags }}
run: |
set -euo pipefail
tag="$(printf '%s\n' "$TAGS" | head -n1)"
docker run --rm --entrypoint sh "$tag" -c \
'command -v nginx >/dev/null && ! command -v php-fpm >/dev/null'
- name: Attest the build (web)
if: github.event_name != 'pull_request'
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ${{ env.REGISTRY }}/${{ env.WEB_IMAGE_NAME }}
subject-digest: ${{ steps.push-web.outputs.digest }}
push-to-registry: true
# Proves the image actually serves phpMyAdmin, not merely that it built.
# A dependency refresh that breaks the application would pass every static
# check and fail here.
smoke-test:
name: smoke test (${{ matrix.variant }})
needs: build
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
strategy:
fail-fast: false
matrix:
variant: [pinned, rolling]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Start the stack
run: |
set -euo pipefail
cp .env.example .env
{
echo "MARIADB_ROOT_PASSWORD=smoke-test-only"
echo "PMA_BLOWFISH_SECRET=smoketestsmoketestsmoketest12345"
if [ "${{ matrix.variant }}" = "rolling" ]; then
echo "PMA_IMAGE_TAG=latest-rolling"
fi
} >> .env
docker compose --profile standalone up -d --wait --wait-timeout 300
- name: phpMyAdmin answers and logs in
run: |
set -euo pipefail
jar=$(mktemp)
code=$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/)
[ "$code" = "200" ] || { echo "::error::login page returned $code"; exit 1; }
token=$(curl -sS -c "$jar" http://127.0.0.1:8080/index.php \
| grep -oE 'name="token" value="[^"]+"' | head -1 | sed 's/.*value="//;s/"//')
[ -n "$token" ] || { echo "::error::no CSRF token in the login form"; exit 1; }
curl -sS -b "$jar" -c "$jar" -o /dev/null \
-X POST -d "pma_username=root&pma_password=smoke-test-only&server=1&target=index.php&token=$token" \
http://127.0.0.1:8080/index.php
# Assert on the absence of the login form, not on a phrase from the
# authenticated page: that wording depends on the phpMyAdmin version
# and the negotiated language. Rejected credentials re-render the
# form, so its presence is the discriminator — verified both ways:
# the unauthenticated page carries `name="pma_username"` exactly
# once, the authenticated one not at all.
page=$(curl -sS -b "$jar" -w '\n%{http_code}' http://127.0.0.1:8080/index.php)
code=${page##*$'\n'}
[ "$code" = "200" ] || { echo "::error::authenticated page returned $code"; exit 1; }
if printf '%s' "$page" | grep -q 'name="pma_username"'; then
echo "::error::still on the login form — the credentials were rejected"
printf '%s' "$page" | grep -oE 'class="alert[^"]*"[^<]*<[^>]*>[^<]{0,200}' | head -3
exit 1
fi
echo "logged in against the database"
- name: php-fpm endpoints are not public
run: |
set -euo pipefail
for path in ping status; do
code=$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:8080/$path")
[ "$code" = "403" ] || { echo "::error::/$path answered $code, expected 403"; exit 1; }
done
- name: Logs on failure
if: failure()
run: docker compose --profile standalone logs