Builds RPM/DEB/APK packages (PHP toolchain, gcc) via GitHub Actions, using reproducible Dockerfile-driven builders.
bin/spp— main PHP build CLI. Usage in workflows:php bin/spp build --type=rpm --debuginfo --phpv=8.4 --libs-onlyandphp bin/spp all --type=rpm --debuginfo --phpv=8.4 [--iteration=N] [--packages=…].--libs-onlyproduces abuildroot/that gets passed to the next stage as a tarball;allconsumes that buildroot and produces packages indist/<type>/.bin/createrepo_static,bin/forgejo-helper— repo-side tools.src/— PHP source (src/Command,src/step,src/package,src/util,src/patches,src/ini,CraftConfig.php,extension.php,package.php).craft.yml— top-level static-php-cli config: extensions list, build options, SPC_* env (CFLAGS, LDFLAGS, etc.). When toolchain/optimization questions come up, look here before guessing.Dockerfile.{rhel,debian,alpine}— builders. Built by.github/workflows/build-images.yml..github/workflows/:build-rpm-modular-packages.yml— Alma 8/9/10 × x86_64/arm64 × PHP 8.2/8.3/8.4/8.5/8.6. Uploads via rsync+SSH then runscreaterepo_con the remote.build-deb-forgejo.yml,build-apk-forgejo.yml— Debian/Alpine builds, push to Forgejo. PHP 8.2–8.6. Each PHP version publishes to its own Forgejo owner (82…86, fromgetForgejoOwner()), so there is no shared index and no default-version problem — users opt in by adding the repo for the version they want.build-images.yml— builds the builder containers (libs-only step exists to save build time).spc-download.yml— produces thedownloads-tarballartifact that the build workflows pull viadawidd6/action-download-artifact.zizmor.yml— workflow security audit.
Dockerfile.rhel is matrix-built per Alma version into ghcr.io/static-php/packages-builder-rhel-{8,9,10}. It installs tar, zstd, gcc-toolset-15, cmake 3.31, re2c, bison, autoconf/automake/libtool, fpm, and a prebuilt php from files.henderkes.com. The autotools trio matters for pre-release PHP: a php-8.6.0* tag archive ships no generated configure, so spc runs ./buildconf --force.
When editing Dockerfile.rhel, remember the per-Alma branches:
- Alma 8/9 use
@ruby:3.3module +source /opt/rh/gcc-toolset-15/enable. - Alma 10 uses plain
ruby+source /usr/lib/gcc-toolset/15-env.source. - Alma 8 has no
re2cpackage — built from source (4.3 tarball). 9/10 install from repo.
Toolchain is chosen by package type, not PHP version:
- RPM (Alma), DEB (Debian), and APK (Alpine) →
gcc16 for all PHP versions (8.2–8.6). No--targetis passed, socraft.yml.twigsetsusing_gcc = not target→GccNativeToolchain. For apk the template additionally setsSPC_LIBC: musl+SPC_MUSL_DYNAMIC—Dockerfile.alpineis a realalpine:3.21image (native musl gcc, not zig cross), sobin/spp test(apk add) runs in real Alpine.
Alpine jobs do NOT use container: — they run on the glibc host and invoke the image via docker run. A musl container: breaks JavaScript actions (checkout/cache/download-artifact/tmate) on arm64: GitHub only ships a musl Node for x64, so an arm64 musl container errors with "JavaScript Actions in Alpine containers are only supported on x64 Linux runners." So build-apk-forgejo.yml keeps checkout/cache/artifact steps on the host and wraps only the build/test/forgejo steps in docker run --rm -v "$GITHUB_WORKSPACE":/build … "$IMAGE" bash -lc '…' (as root — bin/spp's maybeSudo no-ops at euid 0, so apk add works). A GHCR docker login step is required since manual docker run isn't auto-authenticated the way container: is. The rpm/deb jobs still use container: — their images are glibc, so this doesn't apply; don't convert them.
The build-libs-gcc job builds one lib set per (alma, arch) with phpv: "8.4" as the canonical trigger (libs are PHP-version-independent). The downstream build step reuses that single buildroot (buildroot-rpm-alma{V}-{arch}-gcc / buildroot-deb-{arch}-gcc / buildroot-apk-{arch}) for every PHP version. Buildroots ship as cache-YYYY-WW GitHub release assets via the .github/actions/buildroot-cache composite action (pruned by buildroot-cache-cleanup.yml).
PHP 8.6 is still a pre-release, so upstream resolves to whatever php-8.6.0* tag is newest (alpha/beta/RC). Consequences:
-
Skip PHP 8.6.0RC1 because of its upstream packaging issue. The upstream probe excludes that exact tag, and
src/artifact/PhpSource.phpfalls back to beta3 if SPC selects RC1 (explicit RC1 requests fail). RC2 and later resolve normally. -
Version strings carry a tilde. PHP and extension prereleases normalize to
~alphaN,~betaN, and~rcN; development snapshots use~~devso they sort below alpha instead of between beta and rc. APK translates these to_alphaN,_betaN,_rcN, and_alpha0_alpha0for dev. The old_premapping incorrectly placed dev above beta. Never hardcode a particular prerelease marker. -
Independently versioned packages keep their upstream prerelease before the PHP tag.
CreatePackages::getTaggedPackageVersion()builds stable versions as1.13.1_82.35(rpm),1.13.1+php82.35(deb), or1.13.1p82.35(apk), with no+ext/_p0boundary. Prerelease examples are3.6.0~alpha1_86.0~rc3(rpm),3.6.0~alpha1+php86.0~rc3(deb), or3.6.0_alpha1_p86_p0_rc3(apk). APK cannot put a dot in a suffix number, so after a package prerelease the PHP stream and patch use separate_psuffixes;_p0there means PHP patch 0. The package's own version and prerelease take precedence over PHP; alpha2 stays above alpha1 even when alpha1 uses newer PHP. For the same upstream version, PHP major/minor/patch and prerelease determine ordering. Both phase sequences aredev < alpha < beta < rc < release. Forgejo owner IDs stay unchanged (86, etc.). The format applies to extensions, PIE and FrankenPHP across all PHP streams. Removing the old boundary can lower same-version rebuilds; upload guards skip lower candidates, and a higher upstream version upgrades past the old format. Keep parsing old RPM versions for existing repository entries. Packages whose version equals PHP itself keep the full PHP version. Numeric upstream rollbacks still need a rollback version or epoch. -
Use native version comparators.
TestCommandselects packages withdpkg,apk version, andrpmdev-vercmp(available in all Alma builders), not PHP'sversion_compare(). All three workflows runtests/package-versions.phpfor their format before building. It checks all 25 PHP/extension phase combinations, migration past legacy builds, numbered prereleases and PHP version transitions.tests/createrepo-versions.pychecks RPM parsing and module artifacts for the legacy and current formats. -
Never publish a lower candidate.
bin/forgejo-helper uploadpaginates the full authenticated package list and checks DEB/APK archive headers against published versions for the same name and architecture before uploading any file. Forgejo stores APK filenames without architecture suffixes, so read published APK.PKGINFOheaders through the file-ID download links; signed APKs may contain multiple gzip/tar members. A failed per-package verification skips that package and its matching runtime/debuginfo files, while unrelated packages continue uploading. Failure to list the registry still aborts the batch.bin/check-rpm-upgradesreads published primary metadata over the existing SSH connection and compares RPM epochs/versions/releases for the same name, architecture and PHP stream. Both guards skip lower candidates and their matching runtime/debuginfo files, then report exit code 3 for skipped uploads. Forgejo workflows attempt both hosts before failing the job. RPM writes a NUL-delimited allowlist for rsync via--files-from, uploads it before failing the job, and selects repository updates by the successful upload step even when that job fails. Both guards use native package comparators and have regression coverage intests/upgrade-guards.py. -
A pre-release must never become the default module stream. RPM puts every PHP version in one repo and separates them with modularity, so the highest stream would otherwise be installed unasked.
PRERELEASE_STREAMSat the top ofbin/createrepo_staticlists the streams excluded frommodulemd-defaults; drop an entry once that stream goes GA. The stream is still published, so users opt in withdnf module switch-to php-zts:static-8.6. Deb and apk need no equivalent — each version is its own Forgejo repo. -
Not every shared extension compiles or loads yet.
craft.yml'sbuild-options.allow-shared-ext-failuretells static-php-cli to skip such an extension instead of aborting the whole build.
The flag is emitted by exactly one condition, in src/util/TwigRenderer.php: 'allow_shared_ext_failure' => version_compare($phpVersion, '8.6', '>='), consumed by {% if allow_shared_ext_failure %} inside build-options: in config/templates/craft.yml.twig. For ≤ 8.5 the key is simply absent, so spc's own default (false) applies and a shared-extension failure stays fatal exactly as before. Keep the comparison in PHP — Twig's >= on strings is lexical and breaks at 8.10. Do not add the version rule to the workflows; they stay version-agnostic.
Manifest contract. When the option is honoured, spc writes buildroot/skipped-shared-extensions.json on every run, even when nothing was skipped — an absent file means "spc too old", an empty skipped array means "nothing failed":
{
"schema": 1,
"generated_at": "2026-08-07T22:00:00+00:00",
"php_version_id": 80600,
"allow_shared_ext_failure": true,
"skipped": [
{"package": "ext-imagick", "extension": "imagick", "phase": "build", "exception": "StaticPHP\\Exception\\ExecutionException", "message": "…"}
]
}No leading dot in the filename: src/step/RunSPC.php now copies the buildroot with cp -a src/. dst/ (dotfiles included, symlinks preserved), but the manifest is deliberately not a dotfile so it survives a copy that isn't. Key on extension (short name), not package. The packaging step subtracts these from the shared-extension list, and a shared extension with no .so and no skip record is still a hard error: no .so ⇒ no subpackage ⇒ no conf.d drop-in.
Alma 8 ships GNU tar 1.30 — tar --zstd is unsupported (the flag was added in tar 1.31). Pipe through the standalone zstd binary instead (it's in Dockerfile.rhel):
# Pack
tar -cf - buildroot | zstd -o buildroot.tar.zst
# Unpack
zstd -dc buildroot.tar.zst | tar -xf -The deb/apk forgejo workflows still use tar --zstd — that is fine; they don't run on Alma. Don't "fix" them.
Per-Alma failures must not cascade. Pattern used in build-rpm-modular-packages.yml:
- Fan-out job uses
strategy.fail-fast: falseand an explicit, parseablename:likeBuild libs gcc (alma{V} {arch})orBuild (alma{V} {arch} php{V}). The name format is the contract — downstream filters parse it. - Filter job runs with
if: ${{ !cancelled() }}andpermissions: actions: read. It callsgh api repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/jobs --paginate --jq …, filters successful jobs by name regex, sed-extracts the tuple, then jq-builds an{include: [...]}matrix and ananyboolean. Both go to$GITHUB_OUTPUT. - Downstream job does
needs: [filter-job], gates withif: ${{ !cancelled() && needs.filter-job.outputs.any == 'true' }}, and usesmatrix: ${{ fromJson(needs.filter-job.outputs.matrix) }}.
Why !cancelled() not success(): upstream matrix entries are allowed to fail; without !cancelled() the filter job wouldn't run at all.
When adding a new downstream stage to any workflow, follow this pattern rather than relying on needs.*.result (which collapses across the whole matrix).
Before pushing, validate YAML and simulate the jq filters:
# YAML syntax
python3 -c "import yaml,sys; yaml.safe_load(open(sys.argv[1]))" .github/workflows/build-rpm-modular-packages.yml
# Simulate the compute-build-gcc-matrix jq with a mocked "succeeded" (alma arch) string
FULL_MATRIX='{"php-version":["8.2","8.3","8.4","8.5","8.6"],"alma":["8","9","10"],"arch":["x86_64","arm64"]}'
succeeded=$'9 x86_64\n9 arm64\n10 x86_64\n10 arm64'
jq -nc --argjson m "$FULL_MATRIX" --arg s "$succeeded" '
($s | split("\n") | map(select(length > 0))) as $set |
[ $m["php-version"][] as $p | $m.alma[] as $a | $m.arch[] as $r |
select($set | index("\($a) \($r)")) |
{"php-version": $p, alma: $a, arch: $r}
]'The Plan/Explore agents can also read large workflow files, but for these targeted edits prefer Read + Edit directly.
- 4-space indentation throughout (including step bodies).
- Steps written as
- name:(3 spaces between dash and key). Match this when adding new steps. - All third-party
uses:actions are pinned to commit SHAs with a# vXtrailing comment. Keep that style; don't switch to floating tags. FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: trueis set in container jobs because the GH-hosted Node in Alma containers is too old.runs-onfor arm64:ubuntu-24.04-arm(RPM) orubuntu-24.04${{ matrix.arch == 'arm64' && '-arm' || '' }}(DEB style).- Matrix arch is
arm64/x86_64in inputs; the RPM tooling wantsaarch64— the conversion happens in the "Set architecture variables" step (MATRIX_ARCHenv →RPM_ARCH).
All build-* workflows accept comma-separated overrides via workflow_dispatch:
php_versions(e.g.,8.2,8.5) — empty = all.downloads_run_id— pins thespc-downloadsource snapshot; automatic dispatches pass their own run ID, and empty preserves the download action's latest-artifact lookup.alma_versions(RPM only).architectures(x86_64,arm64for RPM/APK;amd64,arm64for DEB).packages— passed through tobin/sppas--packages=….iteration— passed through as--iteration=…and overrides automatic bumping.bumpnow defaults to true in the package workflows; scheduled runs also bump. Forgejo revision queries paginate the full authenticated registry and cache each page.debug_tmate— opens tmate on failure (only after build step; tmate binary is downloaded inline because Alma doesn't ship it).
When the user asks to "rerun for X only", they mean these inputs.
RunSPC resolves minor versions through the snapshot's downloads/.spc-meta.json and verifies the built binary before packaging. CI requires this metadata; explicit full-version requests remain explicit. Keep the PHP stream used for paths, package tags and Forgejo owners separate from the pinned source release.
spc-download runs incremental builds Tuesday–Sunday and forces a full rebuild every Monday through the existing force_full path. This retries missed or failed publications weekly without polling package repositories. Upload downgrade guards still apply.
- Packages are uploaded via
rsyncover SSH to${{ secrets.DEB_SERVER_IP }}:/mnt/data/{rpm|deb|apk}/${RPM_ARCH}/${TARGET_DIR}/. RPMupdate-repochecks out the current code and pipesbin/createrepo_staticinto remotepython3 -, followed bycreaterepo_c --update . && modifyrepo_c …; no separately installed server script needs updating. - RPM signing: GPG key from
secrets.DEB_GPG_PRIVATE_KEY+ passphraseDEB_GPG_PASSWORD, loaded into~/.rpmmacrosand used byrpmsign --addsign. - Cache: composer cache is the only persistent cache (
actions/cachekeyed oncomposer.lock). buildroot-*artifacts are uploaded withretention-days: 1— they're cheap intermediates.
Match the comment density of the file you are editing. Both repos are sparsely commented: a comment earns its place only when it records something the code cannot say — an upstream bug, a non-obvious ordering constraint, why a workaround exists.
Don't narrate. No comment restating the line below it, no explaining what a well-named call does, no multi-line rationale on a one-line change, and no "changed X to Y" or "fixed Z" notes — that belongs in the commit message. When in doubt, leave it out; the diff and the commit message carry the reasoning.
- Don't wire an extension patch with
#[PatchBeforeBuild]— spc emits that only fromPackageBuilder::buildPackage(), which refuses anything that isn't aLibraryPackage, so it reaches libraries and targets but never#[Extension]packages. Extensions gobuildShared()→runStage(), which emits stage hooks: use#[BeforeStage('ext-<name>', 'phpizeForUnix')]to land before phpize/configure/make. A hook wired the wrong way is silent — the extension just fails andallow-shared-ext-failureskips it. - SPC uses bundled
php-src/ext/zipfor PHP 8.5+ and archived PECL zip for older PHP versions. The former PHP 8.6 PECL zip hook and patch are no longer needed. - Don't switch
tar --zstdto pipes in the deb/apk workflows — they don't run on Alma. - Don't skip pinning new actions to SHAs.
- Don't add
container: <alpine image>to the apk jobs — musl containers can't run JS actions on arm64. Keep the host +docker runpattern. - Don't replace the dynamic-matrix filter pattern with
if: needs.X.result == 'success'— that fails closed for the whole matrix when any entry fails. - Don't
git pushorgh pr createunless explicitly asked.