Skip to content
Draft
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
74 changes: 74 additions & 0 deletions .github/workflows/build-frankenphp.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: build frankenphp

# Builds frankenphp with only the extensions the app uses, and pushes it to GHCR. Packages stay on
# upstream's binary until the Makefile points at what this publishes.
on:
# Measures only: reports the size of a build and publishes nothing.
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- .github/workflows/build-frankenphp.yml
- cli/build-frankenphp.sh
workflow_dispatch:
inputs:
revision:
description: Starts at 1 for a frankenphp version, up by one when the extension list changes
type: number
required: true

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
build:
strategy:
fail-fast: false
matrix:
# Linux in the upstream builder's container, macOS natively: a container cannot produce a
# Mach-O binary. Windows is not here, its zip is a PHP install trimmed by leaving files out.
os: [ubuntu-24.04, ubuntu-24.04-arm, macos-15]
runs-on: ${{ matrix.os }}
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false

- run: make frankenphp-build
env:
GITHUB_TOKEN: ${{ github.token }}

- name: Check the binary and report its size
run: |
make frankenphp-smoke
./cli/build-frankenphp.sh size .cache/frankenphp-build/"$(./cli/build-frankenphp.sh asset)" >> "$GITHUB_STEP_SUMMARY"

# Through mise: this repo allows no third-party action beyond a short list.
- uses: jdx/mise-action@v4
if: github.event_name == 'workflow_dispatch'
with:
install_args: oras

- name: Publish
id: publish
if: github.event_name == 'workflow_dispatch'
run: make frankenphp-publish REVISION="$REVISION"
env:
REVISION: ${{ inputs.revision }}
GITHUB_ACTOR: ${{ github.actor }}
GITHUB_TOKEN: ${{ github.token }}

- uses: actions/attest@v4
if: github.event_name == 'workflow_dispatch'
with:
subject-name: ghcr.io/${{ github.repository }}/frankenphp
subject-digest: ${{ steps.publish.outputs.digest }}
push-to-registry: true
21 changes: 18 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
ADMINER_VERSION = 6.1.1
FRANKENPHP_VERSION = 1.13.3

# What cli/build-frankenphp.sh compiles in. composer and phpstan run on this binary too.
FRANKEN_EXTENSIONS = opcache,session,mysqli,mysqlnd,pgsql,sqlite3,pdo,pdo_mysql,pdo_pgsql,pdo_sqlite,openssl,zlib,bz2,zip,mbstring,ctype,iconv,dom,xml,simplexml,tokenizer,fileinfo,filter,phar,curl
FRANKEN_BUILT = .cache/frankenphp-build/$(shell ./cli/build-frankenphp.sh asset)

ADMINER_URL = https://github.com/vrana/adminer/releases/download/v$(ADMINER_VERSION)
FRANKEN_URL = https://github.com/php/frankenphp/releases/download/v$(FRANKENPHP_VERSION)

Expand All @@ -20,7 +24,7 @@ else
EXE = .exe
endif

.PHONY: help install linux-deps fetch verify qa phpstan dresscode dresscode-hook golangci oxc security check check-app e2e e2e-visual e2e-packaged i18n i18n-check mcp-bridge-data bench build run dev editor debug demo down destroy kill-orphans bundle zip dist tarball windows deb logs serve clean checksums
.PHONY: frankenphp-build frankenphp-smoke frankenphp-publish help install linux-deps fetch verify qa phpstan dresscode dresscode-hook golangci oxc security check check-app e2e e2e-visual e2e-packaged i18n i18n-check mcp-bridge-data bench build run dev editor debug demo down destroy kill-orphans bundle zip dist tarball windows deb logs serve clean checksums

.DEFAULT_GOAL := help

Expand Down Expand Up @@ -163,6 +167,16 @@ verify: fetch ## Verify the downloads: adminer checksums, frankenphp build prov
$(SHA256) -c checksums.txt
gh attestation verify $(FRANKEN_DL) --repo php/frankenphp

frankenphp-build: ## Build frankenphp with FRANKEN_EXTENSIONS only (docker on Linux, Homebrew on macOS)
./cli/build-frankenphp.sh build $(FRANKENPHP_VERSION) $(FRANKEN_EXTENSIONS) .cache/frankenphp-build

frankenphp-smoke:
./cli/build-frankenphp.sh smoke $(FRANKEN_BUILT) $(FRANKEN_EXTENSIONS)

# CI only, it needs a token that can write packages. REVISION goes up when the extensions change.
frankenphp-publish:
./cli/build-frankenphp.sh publish $(FRANKEN_BUILT) $$(./cli/build-frankenphp.sh image $(FRANKENPHP_VERSION) $(REVISION))

# Regenerate after a deliberate version bump or a change in patches/. Review the diff.
checksums:
$(SHA256) app/adminer.php app/editor.php > checksums.txt
Expand Down Expand Up @@ -320,15 +334,16 @@ qa: fetch app/vendor i18n mcp-bridge-data ## Run every static check (php, go, j
|| { echo "launcher: no non-darwin definition of $$f() (menu_other.go stub, or *_linux.go + *_other.go or *_windows.go split)"; exit 1; }; \
done && echo "platform stubs ok"
@command -v shellcheck >/dev/null \
&& { shellcheck check.sh cli/compile-adminer.sh cli/kill-orphans.sh cli/demo-mysql.sh tests/cli/kill-orphans-test.sh tests/launcher/downloads.sh && echo "shellcheck ok"; } \
|| { sh -n check.sh && sh -n cli/compile-adminer.sh && sh -n cli/kill-orphans.sh && sh -n cli/demo-mysql.sh && echo "sh ok (shellcheck not installed)"; }
&& { shellcheck check.sh cli/compile-adminer.sh cli/kill-orphans.sh cli/demo-mysql.sh cli/build-frankenphp.sh tests/cli/kill-orphans-test.sh tests/cli/build-frankenphp-test.sh tests/launcher/downloads.sh && echo "shellcheck ok"; } \
|| { sh -n check.sh && sh -n cli/compile-adminer.sh && sh -n cli/kill-orphans.sh && sh -n cli/demo-mysql.sh && sh -n cli/build-frankenphp.sh && echo "sh ok (shellcheck not installed)"; }
@# Native-shell strings: the `i18n` prerequisite already generated the real files (which is
@# also what makes i18n_gen.h exist before `go vet` compiles the cgo launcher above). Assert the
@# generator's output quality and lint the emitted plists. `make i18n-check` reports coverage.
./bin/frankenphp$(EXE) php-cli tests/i18n/GeneratorTest.php
./bin/frankenphp$(EXE) php-cli tests/version/AppVersionTest.php
./bin/frankenphp$(EXE) php-cli tests/shortcuts/ShortcutsTest.php
@sh tests/cli/kill-orphans-test.sh
@sh tests/cli/build-frankenphp-test.sh
@# The launcher's own download code against the real webview, every way adminer starts one.
@sh tests/launcher/downloads.sh
@command -v plutil >/dev/null && plutil -lint Info.plist.in lproj/*/Localizable.strings >/dev/null && echo "i18n + plists ok" || echo "i18n ok (plutil skipped, macOS only)"
Expand Down
97 changes: 97 additions & 0 deletions cli/build-frankenphp.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/bin/sh
# Build frankenphp with only the extensions the app uses: the upstream binary is nearly all of
# every package, and most of it goes unused. See `make frankenphp-build`.
#
# build-frankenphp.sh asset the binary's name on this machine
# build-frankenphp.sh image <version> <revision> the registry reference for that binary
# build-frankenphp.sh build <version> <extensions> <out dir>
# build-frankenphp.sh smoke <binary> <extensions> fail on a missing extension
# build-frankenphp.sh size <binary> unpacked and gzipped, in bytes
# build-frankenphp.sh publish <binary> <reference> push it, and hand CI the digest
#
# GITHUB_TOKEN keeps the build under GitHub's API limit: it makes about 40 requests, and the
# anonymous allowance is 60 an hour.
set -eu

REGISTRY=ghcr.io/landsman/adminer-desktop/frankenphp

# nomercure: frankenphp's own Caddy module imports Mercure unless told not to, whatever
# XCADDY_ARGS says. XCADDY_ARGS must not be empty, or the defaults come back.
GO_TAGS=nomercure
CADDY_MODULES="--with github.com/dunglas/caddy-cbrotli"

asset() {
case "$(uname -s)-$(uname -m)" in
Darwin-arm64) echo frankenphp-mac-arm64 ;;
Linux-x86_64) echo frankenphp-linux-x86_64 ;;
Linux-aarch64 | Linux-arm64) echo frankenphp-linux-aarch64 ;;
*)
echo "no frankenphp build for $(uname -s)-$(uname -m)" >&2
exit 1
;;
esac
}

build() {
version=$1 extensions=$2 out=$3
mkdir -p "$out"
out=$(cd "$out" && pwd)
if [ "$(uname -s)" = Darwin ]; then
src=$(mktemp -d)
git clone -q --depth 1 --branch "v$version" https://github.com/php/frankenphp "$src"
(cd "$src" && OUTPUT_DIR="$out" SPC_LOGS_DIR="$out/log" FRANKENPHP_VERSION="$version" \
PHP_EXTENSIONS="$extensions" XCADDY_ARGS="$CADDY_MODULES" \
SPC_CMD_VAR_FRANKENPHP_GO_TAGS="$GO_TAGS" ./build-static.sh)
else
# Docker because a build pulls in a toolchain nobody wants on the machine. Only Linux
# can: a container cannot produce a Mach-O binary.
docker run --rm -v "$out:/output" -e OUTPUT_DIR=/output -e SPC_LOGS_DIR=/output/log \
-e GITHUB_TOKEN -e PHP_EXTENSIONS="$extensions" -e XCADDY_ARGS="$CADDY_MODULES" \
-e SPC_CMD_VAR_FRANKENPHP_GO_TAGS="$GO_TAGS" \
"dunglas/frankenphp:static-builder-musl-$version"
fi
}

smoke() {
binary=$1 extensions=$2
"$binary" version
loaded=$("$binary" php-cli -r 'echo strtolower(implode("\n", get_loaded_extensions()));')
missing=
for extension in $(echo "$extensions" | tr ',' ' '); do
name=$extension
[ "$name" = opcache ] && name="zend opcache"
echo "$loaded" | grep -Fxq "$name" || missing="$missing $extension"
done
if [ -n "$missing" ]; then
echo "missing from $binary:$missing" >&2
exit 1
fi
echo "all extensions loaded"
}

size() {
echo "$(basename "$1"): $(wc -c <"$1" | tr -d ' ') unpacked, $(gzip -6 -c "$1" | wc -c | tr -d ' ') with gzip -6"
}

publish() {
dir=$(dirname "$1") file=$(basename "$1") reference=$2
echo "$GITHUB_TOKEN" | oras login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
digest=$(cd "$dir" && oras push "$reference" "$file" --format go-template='{{.digest}}')
echo "$reference $digest"
echo "digest=$digest" >>"${GITHUB_OUTPUT:-/dev/null}"
}

command=${1:-}
[ $# -gt 0 ] && shift
case "$command" in
asset) asset ;;
image) echo "$REGISTRY:$1-$2-$(asset | sed 's/^frankenphp-//')" ;;
build) build "$@" ;;
smoke) smoke "$@" ;;
size) size "$@" ;;
publish) publish "$@" ;;
*)
sed -n '5,10p' "$0" >&2
exit 2
;;
esac
1 change: 1 addition & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ activate_aggressive = true
# publishes GitHub build provenance, and `gh attestation verify` is what reads that. It is
# a build dependency like any other — pinned rather than assumed to be on the machine.
[tools]
oras = "1.3.6"
node = "22"
go = "1.26.5"
gh = "2"
Expand Down
73 changes: 73 additions & 0 deletions tests/cli/build-frankenphp-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/bin/sh
# What cli/build-frankenphp.sh would get wrong silently: a binary published with an extension
# missing, or under another platform's name. No network and no real build.
set -u
script="$(cd "$(dirname "$0")/../.." && pwd)/cli/build-frankenphp.sh"
t=$(mktemp -d)
trap 'rm -rf "$t"' EXIT
rc=0

check() {
if [ "$2" = "$3" ]; then
echo "ok $1"
else
echo "FAIL $1: wanted '$2', got '$3'"
rc=1
fi
}

cat >"$t/binary" <<'EOF'
#!/bin/sh
[ "$1" = version ] && { echo "FrankenPHP stub"; exit 0; }
printf 'core\nzend opcache\nmysqli'
EOF
chmod +x "$t/binary"

sh "$script" smoke "$t/binary" opcache,mysqli >/dev/null 2>&1
check "smoke passes when every extension is loaded, opcache by its real name" 0 $?

sh "$script" smoke "$t/binary" mysqli,pgsql >/dev/null 2>&1
check "smoke fails when an extension is missing" 1 $?

# A substring must not count: "mysql" is not loaded just because "mysqli" is.
sh "$script" smoke "$t/binary" mysql >/dev/null 2>&1
check "smoke does not take mysqli for mysql" 1 $?

mkdir "$t/bin"
stub_uname() {
cat >"$t/bin/uname" <<STUB
#!/bin/sh
[ "\$1" = -s ] && echo $1 || echo $2
STUB
chmod +x "$t/bin/uname"
}

check_asset() {
stub_uname "$1" "$2"
check "$1 $2 builds $3" "$3" "$(PATH="$t/bin:$PATH" sh "$script" asset)"
}

check_asset Darwin arm64 frankenphp-mac-arm64
check_asset Linux x86_64 frankenphp-linux-x86_64
check_asset Linux aarch64 frankenphp-linux-aarch64

check "image names the version, revision and platform" \
ghcr.io/landsman/adminer-desktop/frankenphp:1.13.3-2-linux-aarch64 \
"$(PATH="$t/bin:$PATH" sh "$script" image 1.13.3 2)"

stub_uname Plan9 mips
PATH="$t/bin:$PATH" sh "$script" asset >/dev/null 2>&1
check "an unknown platform fails rather than naming a binary" 1 $?

cat >"$t/bin/oras" <<STUB
#!/bin/sh
echo "\$@" >>"$t/oras.log"
[ "\$1" = push ] && echo sha256:abc
exit 0
STUB
chmod +x "$t/bin/oras"
GITHUB_TOKEN=x GITHUB_ACTOR=me GITHUB_OUTPUT="$t/output" PATH="$t/bin:$PATH" sh "$script" publish "$t/binary" registry/image:tag >/dev/null
check "publish hands CI the digest it pushed" "digest=sha256:abc" "$(cat "$t/output")"
check "publish pushes the file to the reference it was given" 1 "$(grep -c 'push registry/image:tag binary' "$t/oras.log")"

exit $rc
Loading