From f9b35c988ecac88386d5631e777b915f686799f4 Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Sun, 1 Nov 2026 00:15:43 +0100 Subject: [PATCH 1/2] ci: build a trimmed frankenphp, upstream's binary is nearly all of every package --- .github/workflows/build-frankenphp.yml | 71 +++++++++++++++++++ Makefile | 21 +++++- cli/build-frankenphp.sh | 97 ++++++++++++++++++++++++++ tests/cli/build-frankenphp-test.sh | 73 +++++++++++++++++++ 4 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/build-frankenphp.yml create mode 100755 cli/build-frankenphp.sh create mode 100755 tests/cli/build-frankenphp-test.sh diff --git a/.github/workflows/build-frankenphp.yml b/.github/workflows/build-frankenphp.yml new file mode 100644 index 00000000..2fe44d53 --- /dev/null +++ b/.github/workflows/build-frankenphp.yml @@ -0,0 +1,71 @@ +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" + + - uses: oras-project/setup-oras@v2 + if: github.event_name == 'workflow_dispatch' + + - 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 diff --git a/Makefile b/Makefile index 20a351c4..4d21763f 100644 --- a/Makefile +++ b/Makefile @@ -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) @@ -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 @@ -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 @@ -320,8 +334,8 @@ 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. @@ -329,6 +343,7 @@ qa: fetch app/vendor i18n mcp-bridge-data ## Run every static check (php, go, j ./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)" diff --git a/cli/build-frankenphp.sh b/cli/build-frankenphp.sh new file mode 100755 index 00000000..542072b1 --- /dev/null +++ b/cli/build-frankenphp.sh @@ -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 the registry reference for that binary +# build-frankenphp.sh build +# build-frankenphp.sh smoke fail on a missing extension +# build-frankenphp.sh size unpacked and gzipped, in bytes +# build-frankenphp.sh publish 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 diff --git a/tests/cli/build-frankenphp-test.sh b/tests/cli/build-frankenphp-test.sh new file mode 100755 index 00000000..d06019f6 --- /dev/null +++ b/tests/cli/build-frankenphp-test.sh @@ -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" </dev/null 2>&1 +check "an unknown platform fails rather than naming a binary" 1 $? + +cat >"$t/bin/oras" <>"$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 From 1dc1e16885f4dd34ee8ab387177d2db49b6f2dab Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Sun, 1 Nov 2026 00:16:32 +0100 Subject: [PATCH 2/2] ci: install oras through mise, the repo's action allowlist refused the workflow at startup --- .github/workflows/build-frankenphp.yml | 5 ++++- mise.toml | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-frankenphp.yml b/.github/workflows/build-frankenphp.yml index 2fe44d53..38c8168d 100644 --- a/.github/workflows/build-frankenphp.yml +++ b/.github/workflows/build-frankenphp.yml @@ -51,8 +51,11 @@ jobs: make frankenphp-smoke ./cli/build-frankenphp.sh size .cache/frankenphp-build/"$(./cli/build-frankenphp.sh asset)" >> "$GITHUB_STEP_SUMMARY" - - uses: oras-project/setup-oras@v2 + # 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 diff --git a/mise.toml b/mise.toml index 440ac7f3..45208735 100644 --- a/mise.toml +++ b/mise.toml @@ -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"