diff --git a/.env b/.env index 10f769d..82ae482 100644 --- a/.env +++ b/.env @@ -2,7 +2,7 @@ export XDG_CONFIG_HOME=$HOME/.config export XDG_DATA_HOME=$HOME/.data export XDG_CACHE_HOME=$HOME/.cache export XDG_STATE_HOME=$HOME/.state -export PNPM_STORE_PATH=${XDG_DATA_HOME}/pnpm-store +export PNPM_STORE_PATH=$HOME/.pnpm-store export ZSH="$HOME/.oh-my-zsh" mkdir -p $XDG_CONFIG_HOME $XDG_DATA_HOME $XDG_CACHE_HOME $XDG_STATE_HOME $PNPM_STORE_PATH @@ -13,4 +13,4 @@ alias stan="./vendor/bin/phpstan" alias phpstan="./vendor/bin/phpstan" alias pest="./vendor/bin/pest" alias amf="php artisan migrate:fresh" -alias amfs="php artisan migrate:fresh --seed" \ No newline at end of file +alias amfs="php artisan migrate:fresh --seed" diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index b0b6ba4..921980a 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-24.04 outputs: # Push if it's a scheduled job, a tag, or if we're committing to the main branch - push: ${{ (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.version) || (github.ref == 'refs/heads/main' && github.event_name != 'pull_request')) && true || false }} + push: ${{ github.event_name == 'schedule' || (github.ref == 'refs/heads/main' && github.event_name != 'pull_request') }} variants: ${{ steps.matrix.outputs.variants }} platforms: ${{ steps.matrix.outputs.platforms }} metadata: ${{ steps.matrix.outputs.metadata }} @@ -65,15 +65,15 @@ jobs: done <<< "$ALL_PHP_VERSIONS" # Get the last 2 minor versions - MINOR_VERSIONS_SORTED=($(printf '%s\n' "${!LATEST_MINOR_VERSIONS[@]}" | sort -V | tail -2)) + mapfile -t MINOR_VERSIONS_SORTED < <(printf '%s\n' "${!LATEST_MINOR_VERSIONS[@]}" | sort -V | tail -2) if [[ ${#MINOR_VERSIONS_SORTED[@]} -lt 2 ]]; then echo "โŒ Could not find 2 different minor versions" exit 1 fi - PHP_VERSION_1="8.3" - PHP_VERSION_2="8.4" + PHP_VERSION_1="${MINOR_VERSIONS_SORTED[0]}" + PHP_VERSION_2="${MINOR_VERSIONS_SORTED[1]}" echo "โœ… Selected PHP minor versions: ${PHP_VERSION_1} and ${PHP_VERSION_2}" echo " Latest patches: ${LATEST_MINOR_VERSIONS[$PHP_VERSION_1]} and ${LATEST_MINOR_VERSIONS[$PHP_VERSION_2]}" @@ -199,6 +199,7 @@ jobs: env: PHP_VERSION: ${{ steps.check.outputs.php_version }} LATEST: ${{ steps.check.outputs.latest_version }} + SHA: ${{ github.sha }} build: runs-on: ${{ startsWith(matrix.platform, 'linux/arm') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} needs: @@ -265,6 +266,7 @@ jobs: env: PHP_VERSION: ${{ needs.prepare.outputs.php_version }} LATEST: ${{ needs.prepare.outputs.latest_version }} + SHA: ${{ github.sha }} - name: Upload image artifact uses: actions/upload-artifact@v4 @@ -367,11 +369,10 @@ jobs: - name: Test image environment run: | # Get the built image name - if [[ "${{ matrix.variant }}" == *"-dev" ]]; then - ENV_TYPE="dev" - else - ENV_TYPE="production" - fi + case "${{ matrix.variant }}" in + *-dev) ENV_TYPE="dev" ;; + *) ENV_TYPE="production" ;; + esac echo "๐Ÿงช Testing ${ENV_TYPE} environment in image: ${LOADED_IMAGE_ID}" diff --git a/.zshrc b/.zshrc index b97e663..fa86117 100644 --- a/.zshrc +++ b/.zshrc @@ -8,45 +8,43 @@ if [ -f ~/.bash_aliases ]; then . ~/.bash_aliases fi -# Zinit installation +# Load the build-pinned Zinit installation ZINIT_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}/zinit/zinit.git" -if [[ ! -d "$ZINIT_HOME" ]]; then - mkdir -p "$(dirname $ZINIT_HOME)" - git clone https://github.com/zdharma-continuum/zinit.git "$ZINIT_HOME" +if [[ ! -r "$ZINIT_HOME/zinit.zsh" ]]; then + print -u2 "Missing pinned Zinit installation: $ZINIT_HOME" + return 1 fi source "${ZINIT_HOME}/zinit.zsh" -# Completions path -export FPATH="$HOME/.eza/completions/zsh:$FPATH" +# Initialize completion before sourcing Oh My Zsh plugins that register compdefs +autoload -Uz compinit +compinit # Essential plugins (loaded immediately) +zinit ice ver"85919cd1ffa7d2d5412f6d3fe437ebdbeeec4fc5" zinit light zsh-users/zsh-autosuggestions +zinit ice ver"24105b15714bfec37989ed5c5b6e60f572253019" zinit light Aloxaf/fzf-tab -# OMZ libs (only essential ones, loaded with turbo) -zinit wait lucid for \ - OMZL::git.zsh \ - OMZL::completion.zsh - -# OMZ plugins (turbo mode - deferred loading) -zinit wait lucid for \ - OMZP::git \ - OMZP::aws \ - OMZP::gh +# Build-pinned Oh My Zsh libraries and plugins +source "$ZSH/lib/git.zsh" +source "$ZSH/lib/completion.zsh" +source "$ZSH/plugins/git/git.plugin.zsh" +source "$ZSH/plugins/aws/aws.plugin.zsh" +source "$ZSH/plugins/gh/gh.plugin.zsh" # Syntax highlighting (load last, with turbo) -zinit wait lucid for \ - atinit"zicompinit; zicdreplay" \ +zinit wait lucid ver"3d574ccf48804b10dca52625df13da5edae7f553" for \ zdharma-continuum/fast-syntax-highlighting # Tool initializations (synchronous - needed for prompt) eval "$(starship init zsh)" eval "$(zoxide init zsh --cmd cd)" -# FZF (turbo) -zinit wait lucid for \ - atload"source <(fzf --zsh)" \ - zdharma-continuum/null +# FZF integration +if [[ -t 0 && -t 1 ]]; then + source <(fzf --zsh) +fi # Eza aliases (immediate, not deferred) alias l='eza -lah --icons --git --group-directories-first' @@ -95,4 +93,3 @@ zstyle ':fzf-tab:complete:(ls|l|ll|la|lt|eza):*' fzf-preview '[[ -d $realpath ]] zstyle ':fzf-tab:*' fzf-flags --color=fg:1,fg+:2 --bind=tab:accept zstyle ':fzf-tab:*' use-fzf-default-opts yes zstyle ':fzf-tab:*' switch-group '<' '>' - diff --git a/.zshrc.prod b/.zshrc.prod index acf736e..937f2d3 100644 --- a/.zshrc.prod +++ b/.zshrc.prod @@ -5,25 +5,28 @@ if [ -f ~/.bash_aliases ]; then . ~/.bash_aliases fi -# Zinit installation +# Load the build-pinned Zinit installation ZINIT_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}/zinit/zinit.git" -if [[ ! -d "$ZINIT_HOME" ]]; then - mkdir -p "$(dirname $ZINIT_HOME)" - git clone https://github.com/zdharma-continuum/zinit.git "$ZINIT_HOME" +if [[ ! -r "$ZINIT_HOME/zinit.zsh" ]]; then + print -u2 "Missing pinned Zinit installation: $ZINIT_HOME" + return 1 fi source "${ZINIT_HOME}/zinit.zsh" +# Initialize completion before sourcing Oh My Zsh plugins that register compdefs +autoload -Uz compinit +compinit + # Minimal plugins for production (loaded immediately) +zinit ice ver"85919cd1ffa7d2d5412f6d3fe437ebdbeeec4fc5" zinit light zsh-users/zsh-autosuggestions -# OMZ libs and git plugin -zinit wait lucid for \ - OMZL::git.zsh \ - OMZP::git +# Build-pinned Oh My Zsh library and git plugin +source "$ZSH/lib/git.zsh" +source "$ZSH/plugins/git/git.plugin.zsh" # Syntax highlighting (load last) -zinit wait lucid for \ - atinit"zicompinit; zicdreplay" \ +zinit wait lucid ver"3d574ccf48804b10dca52625df13da5edae7f553" for \ zdharma-continuum/fast-syntax-highlighting # Basic aliases diff --git a/AGENTS.md b/AGENTS.md index 00941fe..a41951b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,33 +1,29 @@ -# Agent Guidelines for FrankenPHP Docker Images +# Repository Guidelines -## Build Commands +## Project Structure & Module Organization -- **Build all variants**: `docker buildx bake` -- **Build specific variant**: `docker buildx bake runner-php-8-4-bookworm` -- **Build with push**: `docker buildx bake --push` -- **Print build matrix**: `docker buildx bake --print` +This repository builds the `ghcr.io/prvious/frankenphp` development and production images. `Dockerfile` defines the binary/tool builders and the shared `base`, `dev`, and `prod` stages. `docker-bake.hcl` expands PHP versions and targets across `linux/amd64` and `linux/arm64`. Container behavior is configured by `.env`, `.zshrc`, and `.zshrc.prod`. Executable Laravel health checks live under `usr/local/bin/`. Image validation is centralized in `test.php`, while `.github/workflows/pipeline.yml` builds, tests, and publishes the matrix. -## Test Commands +## Build, Test, and Development Commands -- **Test image**: `docker run --rm -it php -v` -- **Test container**: `docker run --rm -p 80:80 ` -- **Run with shell**: `docker run --rm -it bash` +- `docker build --build-arg VERSION=8.5-trixie --target dev -t frankenphp:dev .` builds the local development image. +- `docker build --build-arg VERSION=8.5-trixie --target prod -t frankenphp:prod .` builds the production image. +- `docker buildx bake --print` inspects the default PHP 8.4/8.5 matrix; override `PHP_VERSION`, `SHA`, and `LATEST` to reproduce CI inputs. +- `docker buildx bake` builds all configured variants and architectures. +- `docker run --rm -v "$PWD/test.php:/app/test.php" frankenphp:dev php /app/test.php dev` validates extensions, binaries, and pnpm configuration. Substitute the production image and `production` for that target. -## Available Aliases (from env.sh) +## Coding Style & Naming Conventions -- `pint` - Laravel Pint formatter: `./vendor/bin/pint` -- `pa` - PHP Artisan: `php artisan` -- `stan`/`phpstan` - PHPStan: `./vendor/bin/phpstan` -- `pest` - Pest testing: `./vendor/bin/pest` -- `amf` - Migrate fresh: `php artisan migrate:fresh` -- `amfs` - Migrate fresh with seed: `php artisan migrate:fresh --seed` +Use four-space indentation in PHP, HCL, and workflow YAML. Keep PHP strictly typed and follow the existing PSR-12-style class and method layout; use `camelCase` methods and `UPPER_SNAKE_CASE` constants. In Dockerfile steps, group related packages, use uppercase build arguments, quote shell variables, and clean package caches in the same layer. Health-check filenames use the `healthcheck-` pattern; the Dockerfile copies them into the image as executable POSIX shell scripts. -## Code Style Guidelines +## Testing Guidelines -- **HCL**: 4-space indentation, descriptive variables (e.g., `IMAGE_NAME`, `PHP_VERSION`) -- **Shell**: Use `set -exo pipefail`, proper quoting, environment variable prefixes -- **Docker**: Multi-platform builds (`linux/amd64`, `linux/arm64`), proper labels -- **Tags**: Use semantic versioning, clean tag function for sanitization -- **Functions**: HCL functions for reusable logic (tag generation, version parsing) -- **Matrix builds**: Support multiple PHP versions and OS variants -- **Labels**: Include OpenContainers standard labels with metadata +There is no external test framework or coverage threshold. `test.php` is the acceptance suite. Update its expected extension and binary arrays whenever image contents change, then test both `dev` and `production`. For architecture-sensitive changes, run the Buildx matrix or rely on the PR workflow for both supported platforms. + +## Commit & Pull Request Guidelines + +Prefer short, imperative Conventional Commit subjects such as `feat: add healthcheck script`, `fix: correct pnpm path`, or `chore: remove package`; avoid `wip` commits in review-ready branches. Pull requests should explain the affected stage or variant, note tag/platform impact, link relevant issues, and list exact build and test commands with results. Screenshots are only useful for user-visible shell behavior. + +## Security & Configuration + +`.env` is copied into `/etc/profile.d/.env`; keep it limited to non-secret container defaults and aliases. Never commit registry tokens, credentials, or application secrets. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 00941fe..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Guidelines for FrankenPHP Docker Images - -## Build Commands - -- **Build all variants**: `docker buildx bake` -- **Build specific variant**: `docker buildx bake runner-php-8-4-bookworm` -- **Build with push**: `docker buildx bake --push` -- **Print build matrix**: `docker buildx bake --print` - -## Test Commands - -- **Test image**: `docker run --rm -it php -v` -- **Test container**: `docker run --rm -p 80:80 ` -- **Run with shell**: `docker run --rm -it bash` - -## Available Aliases (from env.sh) - -- `pint` - Laravel Pint formatter: `./vendor/bin/pint` -- `pa` - PHP Artisan: `php artisan` -- `stan`/`phpstan` - PHPStan: `./vendor/bin/phpstan` -- `pest` - Pest testing: `./vendor/bin/pest` -- `amf` - Migrate fresh: `php artisan migrate:fresh` -- `amfs` - Migrate fresh with seed: `php artisan migrate:fresh --seed` - -## Code Style Guidelines - -- **HCL**: 4-space indentation, descriptive variables (e.g., `IMAGE_NAME`, `PHP_VERSION`) -- **Shell**: Use `set -exo pipefail`, proper quoting, environment variable prefixes -- **Docker**: Multi-platform builds (`linux/amd64`, `linux/arm64`), proper labels -- **Tags**: Use semantic versioning, clean tag function for sanitization -- **Functions**: HCL functions for reusable logic (tag generation, version parsing) -- **Matrix builds**: Support multiple PHP versions and OS variants -- **Labels**: Include OpenContainers standard labels with metadata diff --git a/Dockerfile b/Dockerfile index cb15c91..239139b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,147 @@ ARG VERSION -FROM dunglas/frankenphp:php${VERSION} AS base +FROM dunglas/frankenphp:php${VERSION} AS frankenphp-base + +#--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +# BUILDER STAGE: Download pinned binaries and tools +#--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + +FROM debian:bookworm-slim AS binaries-builder + +ARG TARGETARCH +ARG NODE_VERSION=26.5.1 +ARG PNPM_VERSION=11.18.0 +ARG SVGO_VERSION=4.0.2 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + gzip \ + libatomic1 \ + tar \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /tmp/binaries + +# Download Node.js without npm and verify the pinned archive +RUN case "${TARGETARCH}" in \ + amd64) NODE_ARCH="x64"; NODE_SHA256="2b07f09c218d473a26442bff5a90151f53f7b7c0a23bad244eda2c26303a2ba7" ;; \ + arm64) NODE_ARCH="arm64"; NODE_SHA256="21194bbf41c18d9ec277545c4d14cce8597d57a9d9f494c323d8121a25de33e8" ;; \ + *) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && NODE_ARCHIVE="node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz" \ + && curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/${NODE_ARCHIVE}" -o "${NODE_ARCHIVE}" \ + && echo "${NODE_SHA256} ${NODE_ARCHIVE}" | sha256sum -c - \ + && tar -xzf "${NODE_ARCHIVE}" \ + && mkdir -p /tmp/binaries/node/bin \ + && mv "node-v${NODE_VERSION}-linux-${NODE_ARCH}/bin/node" /tmp/binaries/node/bin/ \ + && rm -rf "${NODE_ARCHIVE}" "node-v${NODE_VERSION}-linux-${NODE_ARCH}" + +# Download pnpm's architecture-independent package and verify the pinned archive +RUN PNPM_ARCHIVE="pnpm-${PNPM_VERSION}.tgz" \ + && curl -fsSL "https://registry.npmjs.org/pnpm/-/${PNPM_ARCHIVE}" -o "${PNPM_ARCHIVE}" \ + && echo "29c35ca8d2a287988fdee3e0f36e07d9b93783f567b579b7fd5b798a4563dd81 ${PNPM_ARCHIVE}" | sha256sum -c - \ + && mkdir -p /tmp/binaries/pnpm \ + && tar -xzf "${PNPM_ARCHIVE}" -C /tmp/binaries/pnpm --strip-components=1 \ + && ln -s bin/pnpm.mjs /tmp/binaries/pnpm/pnpm \ + && rm -f "${PNPM_ARCHIVE}" + +# Install a pinned svgo release using the verified Node.js and pnpm binaries +RUN mkdir -p /tmp/binaries/pnpm/bin /tmp/binaries/pnpm-global \ + && export PATH="/tmp/binaries/node/bin:/tmp/binaries/pnpm:/tmp/binaries/pnpm/bin:$PATH" \ + && export PNPM_HOME=/tmp/binaries/pnpm \ + && pnpm config set store-dir /tmp/pnpm-store --global \ + && pnpm config set global-dir /tmp/binaries/pnpm-global --global \ + && pnpm config set global-bin-dir /tmp/binaries/pnpm/bin --global \ + && pnpm add -g "svgo@${SVGO_VERSION}" + +# Download and verify development-only binaries +FROM binaries-builder AS dev-binaries-builder + +ARG EZA_VERSION=0.23.5 +ARG FZF_VERSION=0.74.1 +ARG GH_VERSION=2.96.0 +ARG OPENCODE_VERSION=1.18.9 +ARG STARSHIP_VERSION=1.26.0 +ARG ZOXIDE_VERSION=0.10.0 + +RUN mkdir -p /tmp/binaries/dev \ + && case "${TARGETARCH}" in \ + amd64) \ + EZA_ARCH="x86_64"; EZA_SHA256="35c70c5c43c29108075e58b893234c67ef585f0b53a7eaf8e9e7d4eec9f339b4"; \ + FZF_ARCH="amd64"; FZF_SHA256="df53438be5f51e151bb4044d78fda72bdfe209e3ecd2baecae48e8dea370c81b"; \ + GH_ARCH="amd64"; GH_SHA256="83d5c2ccad5498f58bf6368acb1ab32588cf43ab3a4b1c301bf36328b1c8bd60"; \ + OPENCODE_ARCH="x64"; OPENCODE_SHA256="a0fa4b7b8bdacbd013e79a5f69d4220d36b545cd3ea296ba765f3016fa501b5b"; \ + STARSHIP_ARCH="x86_64"; STARSHIP_SHA256="b7c232b0e8249d8e55a40beb79c5c43a7d370f3f9408bd215deb0170daeaadf3"; \ + ZOXIDE_ARCH="x86_64"; ZOXIDE_SHA256="2d93385b99f3e82cf2701609a1bffcad863fbeb75aa3fe7eb6be4d29be68b1ae" \ + ;; \ + arm64) \ + EZA_ARCH="aarch64"; EZA_SHA256="40b87ae8628aa2ff0f0d2dc24ab52f689631366385c3da630bae745671fd71ec"; \ + FZF_ARCH="arm64"; FZF_SHA256="f22204dd1a091d43e102268d062fd53b47133c8d8581671ee5eb225b75e31183"; \ + GH_ARCH="arm64"; GH_SHA256="06f86ec7103d41993b76cd78072f43595c34aaa56506d971d9860e67140bf909"; \ + OPENCODE_ARCH="arm64"; OPENCODE_SHA256="b16bd7593ea960a25d9c6849b3023bcd9b9244a6f51675341fd2052043b0670f"; \ + STARSHIP_ARCH="aarch64"; STARSHIP_SHA256="dc30189378d2f2e287384e8a692d3f95ad1df64cf0e8c36aa9201516028aed6b"; \ + ZOXIDE_ARCH="aarch64"; ZOXIDE_SHA256="f1f16c5d6298d63dee467eedea1cdcd8490e43e493bea43acd416dc9033ef641" \ + ;; \ + *) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && EZA_ARCHIVE="eza_${EZA_ARCH}-unknown-linux-gnu.tar.gz" \ + && curl -fsSL "https://github.com/eza-community/eza/releases/download/v${EZA_VERSION}/${EZA_ARCHIVE}" -o "${EZA_ARCHIVE}" \ + && echo "${EZA_SHA256} ${EZA_ARCHIVE}" | sha256sum -c - \ + && tar -xzf "${EZA_ARCHIVE}" -C /tmp/binaries/dev \ + && FZF_ARCHIVE="fzf-${FZF_VERSION}-linux_${FZF_ARCH}.tar.gz" \ + && curl -fsSL "https://github.com/junegunn/fzf/releases/download/v${FZF_VERSION}/${FZF_ARCHIVE}" -o "${FZF_ARCHIVE}" \ + && echo "${FZF_SHA256} ${FZF_ARCHIVE}" | sha256sum -c - \ + && tar -xzf "${FZF_ARCHIVE}" -C /tmp/binaries/dev \ + && GH_ARCHIVE="gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" \ + && curl -fsSL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/${GH_ARCHIVE}" -o "${GH_ARCHIVE}" \ + && echo "${GH_SHA256} ${GH_ARCHIVE}" | sha256sum -c - \ + && tar -xzf "${GH_ARCHIVE}" \ + && mv "gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh" /tmp/binaries/dev/gh \ + && OPENCODE_ARCHIVE="opencode-linux-${OPENCODE_ARCH}.tar.gz" \ + && curl -fsSL "https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/${OPENCODE_ARCHIVE}" -o "${OPENCODE_ARCHIVE}" \ + && echo "${OPENCODE_SHA256} ${OPENCODE_ARCHIVE}" | sha256sum -c - \ + && tar -xzf "${OPENCODE_ARCHIVE}" -C /tmp/binaries/dev \ + && STARSHIP_ARCHIVE="starship-${STARSHIP_ARCH}-unknown-linux-musl.tar.gz" \ + && curl -fsSL "https://github.com/starship/starship/releases/download/v${STARSHIP_VERSION}/${STARSHIP_ARCHIVE}" -o "${STARSHIP_ARCHIVE}" \ + && echo "${STARSHIP_SHA256} ${STARSHIP_ARCHIVE}" | sha256sum -c - \ + && tar -xzf "${STARSHIP_ARCHIVE}" -C /tmp/binaries/dev \ + && ZOXIDE_ARCHIVE="zoxide-${ZOXIDE_VERSION}-${ZOXIDE_ARCH}-unknown-linux-musl.tar.gz" \ + && curl -fsSL "https://github.com/ajeetdsouza/zoxide/releases/download/v${ZOXIDE_VERSION}/${ZOXIDE_ARCHIVE}" -o "${ZOXIDE_ARCHIVE}" \ + && echo "${ZOXIDE_SHA256} ${ZOXIDE_ARCHIVE}" | sha256sum -c - \ + && mkdir -p /tmp/zoxide \ + && tar -xzf "${ZOXIDE_ARCHIVE}" -C /tmp/zoxide \ + && mv /tmp/zoxide/zoxide /tmp/binaries/dev/zoxide \ + && chmod 755 /tmp/binaries/dev/* \ + && rm -rf ./*.tar.gz ./gh_* /tmp/zoxide + +#--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +# BUILDER STAGE: Pin the shell framework without executing a remote installer +#--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + +FROM debian:bookworm-slim AS shell-builder + +ARG ZINIT_COMMIT=429ab136312dfce68ad7d87a0ecb08c5063e7287 +ARG OH_MY_ZSH_COMMIT=7ea697fd8138550ddf7262456d412f0dcd1cbf84 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + git \ + && rm -rf /var/lib/apt/lists/* + +RUN git clone --filter=blob:none https://github.com/zdharma-continuum/zinit.git /tmp/zinit \ + && git -C /tmp/zinit checkout "${ZINIT_COMMIT}" \ + && test "$(git -C /tmp/zinit rev-parse HEAD)" = "${ZINIT_COMMIT}" \ + && git clone --filter=blob:none https://github.com/ohmyzsh/ohmyzsh.git /tmp/oh-my-zsh \ + && git -C /tmp/oh-my-zsh checkout "${OH_MY_ZSH_COMMIT}" \ + && test "$(git -C /tmp/oh-my-zsh rev-parse HEAD)" = "${OH_MY_ZSH_COMMIT}" + +FROM composer:2.10.2@sha256:5946476338742b200bb9ff88f8be56275ddae4b3949c72305cb0dbf10cfcb760 AS composer-builder + +#--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +# BASE STAGE: FrankenPHP base with minimal dependencies +#--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + +FROM frankenphp-base AS base SHELL [ "/bin/bash", "-l", "-exo", "pipefail", "-c" ] @@ -15,106 +157,152 @@ ENV SERVER_NAME=:80 ENV DEBIAN_FRONTEND=noninteractive ENV PNPM_HOME=/usr/local/share/pnpm ENV PATH=$PNPM_HOME:$PATH -ENV PNPM_STORE_DIR=/home/${USER}/.pnpm-store - -COPY ./.env /etc/profile.d/.env -COPY ./usr/local/bin/* /usr/local/bin/ +ENV pnpm_config_store_dir=/home/${USER}/.pnpm-store -RUN apt update \ - && apt-get install -y gnupg lsb-release ca-certificates curl \ - && echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ - && curl -sSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /etc/apt/trusted.gpg.d/pgdg.gpg \ +# Install shared runtime packages and image tools from the target Debian suite +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + ffmpeg \ + gifsicle \ + git \ + jpegoptim \ + libatomic1 \ + libavif-bin \ + optipng \ + pngquant \ + procps \ + supervisor \ + unzip \ + zsh \ + && . /etc/os-release \ + && install -d -m 0755 /etc/apt/keyrings \ + && curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc -o /etc/apt/keyrings/postgresql.asc \ + && echo "0144068502a1eddd2a0280ede10ef607d1ec592ce819940991203941564e8e76 /etc/apt/keyrings/postgresql.asc" | sha256sum -c - \ + && echo "deb [signed-by=/etc/apt/keyrings/postgresql.asc] http://apt.postgresql.org/pub/repos/apt ${VERSION_CODENAME}-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ && apt-get update \ - && apt-get install -y supervisor git unzip postgresql-client-17 default-mysql-client zsh procps \ + && apt-get install -y --no-install-recommends \ + postgresql-client-17 \ + default-mysql-client \ && echo 'source /etc/profile.d/.env' >> /etc/bash.bashrc \ - && curl -fsSL https://get.pnpm.io/install.sh | env PNPM_HOME="${PNPM_HOME}" bash - \ - && export PATH="${PNPM_HOME}:${PATH}" \ - && pnpm config set store-dir /home/${USER}/.pnpm-store --global \ - && pnpm env use --global 24 \ - && npm install -g npm \ - && apt install -y jpegoptim optipng pngquant gifsicle libavif-bin ffmpeg \ - && pnpm add -g svgo \ - && install-php-extensions @composer mysqli pdo_mysql pgsql pdo_pgsql bcmath gd imagick imap pcntl zip intl exif ftp xml pdo_sqlsrv sqlsrv sockets \ - && cp "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" \ - && groupadd --force -g $WWWGROUP ${USER} \ - && useradd -m --no-user-group -o -g $WWWGROUP -u ${WWWUSER} -s /bin/zsh ${USER} \ - && setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/frankenphp \ - && chmod +x /usr/local/bin/* \ - && mkdir -p /home/${USER}/.config/psysh \ - && chown -R ${USER}:${USER} /home/${USER}/.config \ - && chown -R ${USER}:${USER} /data/caddy && chown -R ${USER}:${USER} /config/caddy && chown -R ${USER}:${USER} /app \ - && apt-get -y autoremove \ && apt-get clean \ - && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + && rm -rf \ + /usr/share/doc/* \ + /usr/share/man/* \ + /var/lib/apt/lists/* + +# PHP extensions +RUN install-php-extensions \ + mysqli \ + pdo_mysql \ + pgsql \ + pdo_pgsql \ + bcmath \ + gd \ + imagick \ + imap \ + pcntl \ + zip \ + intl \ + exif \ + ftp \ + xml \ + pdo_sqlsrv-5.13.1 \ + sqlsrv-5.13.1 \ + sockets \ + && cp "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" + +# Copy runtime configuration and verified artifacts after the expensive package layers +COPY ./.env /etc/profile.d/.env +COPY --chown=0:0 --chmod=755 ./usr/local/bin/* /usr/local/bin/ +COPY --from=composer-builder --chown=0:0 --chmod=755 /usr/bin/composer /usr/bin/composer +COPY --from=binaries-builder --chown=0:0 /tmp/binaries/node /usr/local/node +COPY --from=binaries-builder --chown=0:0 /tmp/binaries/pnpm /usr/local/share/pnpm +COPY --from=binaries-builder --chown=0:0 /tmp/binaries/pnpm-global /usr/local/share/pnpm-global +COPY --from=binaries-builder --chown=0:0 /tmp/pnpm-store /usr/local/pnpm-store + +# Add Node.js, pnpm, and global packages to PATH +ENV PATH=/usr/local/node/bin:/usr/local/share/pnpm:/usr/local/share/pnpm/bin:/usr/local/share/pnpm-global/bin:$PATH + +# User creation and permissions +RUN groupadd --non-unique -g "${WWWGROUP}" "${USER}" \ + && useradd -m --no-user-group -o -g "${WWWGROUP}" -u "${WWWUSER}" -s /bin/zsh "${USER}" \ + && setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/frankenphp \ + && mkdir -p \ + "/home/${USER}/.config/psysh" \ + "/home/${USER}/.local/bin" \ + "/home/${USER}/.pnpm-store" \ + /app \ + /config/opencode \ + /data/opencode \ + && chown -R "${USER}:${USER}" \ + "/home/${USER}" \ + /app \ + /data/caddy \ + /data/opencode \ + /config/caddy \ + /config/opencode #--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +# DEV STAGE +#--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- FROM base AS dev +# Copy verified development binaries +COPY --from=dev-binaries-builder --chown=0:0 --chmod=755 /tmp/binaries/dev/* /usr/local/bin/ + +# Minimal development packages RUN apt-get update \ - && install-php-extensions xdebug \ - && (type -p wget >/dev/null || (apt update && apt install wget -y)) \ - && mkdir -p -m 755 /etc/apt/keyrings \ - && out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \ - && cat $out | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \ - && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ - && mkdir -p -m 755 /etc/apt/sources.list.d \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ - && apt-get update \ - && apt-get install -y htop nano gh zsh \ - && curl -sS https://starship.rs/install.sh | sh -s -- --yes \ - && mkdir -p /etc/apt/keyrings \ - && wget -qO- https://raw.githubusercontent.com/eza-community/eza/main/deb.asc | gpg --dearmor -o /etc/apt/keyrings/gierens.gpg \ - && echo "deb [signed-by=/etc/apt/keyrings/gierens.gpg] http://deb.gierens.de stable main" | tee /etc/apt/sources.list.d/gierens.list \ - && chmod 644 /etc/apt/keyrings/gierens.gpg /etc/apt/sources.list.d/gierens.list \ - && apt update \ - && apt install -y eza \ - && pnpm add -g opencode-ai \ - && apt-get -y autoremove \ + && apt-get install -y --no-install-recommends \ + htop \ + nano \ && apt-get clean \ - && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + && rm -rf /var/lib/apt/lists/* -# Copy zshrc first so we can pre-download plugins -COPY --chown=${USER}:${USER} ./.zshrc /home/${USER}/.zshrc +# Development PHP extensions +RUN install-php-extensions xdebug -# Create directories for user tools -RUN mkdir -p /home/${USER}/.local/share /home/${USER}/.config /home/${USER}/.fzf \ - && chown -R ${USER}:${USER} /home/${USER} +# Copy the pinned shell frameworks and development configuration +COPY --from=shell-builder --chown=${USER}:${USER} /tmp/zinit /home/${USER}/.data/zinit/zinit.git +COPY --from=shell-builder --chown=${USER}:${USER} /tmp/oh-my-zsh /home/${USER}/.oh-my-zsh +COPY --chown=${USER}:${USER} ./.zshrc /home/${USER}/.zshrc -# Switch to the deploy user to install zinit and tools +# Switch to the runtime user and pre-download pinned shell plugins USER ${USER} -# Install zinit, fzf, zoxide, starship preset, and pre-download all zinit plugins -RUN ZINIT_HOME="/home/${USER}/.local/share/zinit" NO_EDIT=1 NO_TUTORIAL=1 \ - bash -c "$(curl --fail --show-error --silent --location https://raw.githubusercontent.com/zdharma-continuum/zinit/HEAD/scripts/install.sh)" \ - && git clone --depth 1 https://github.com/junegunn/fzf.git /home/${USER}/.fzf \ - && /home/${USER}/.fzf/install --all --no-update-rc \ - && curl -sSfL https://raw.githubusercontent.com/ajeetdsouza/zoxide/main/install.sh | bash \ - && mkdir -p /home/${USER}/.config \ - && starship preset no-nerd-font -o /home/${USER}/.config/starship.toml \ - && zsh -i -c 'zinit self-update && exit 0' || true +ENV PATH=/home/${USER}/.local/bin:/home/${USER}/.opencode/bin:$PATH -# Add user bin directories to PATH for fzf and zoxide -ENV PATH=/home/deploy/.local/bin:/home/deploy/.fzf/bin:$PATH +RUN starship preset no-nerd-font -o "/home/${USER}/.config/starship.toml" \ + && zsh -i -c 'exit 0' \ + && test "$(git -C "/home/${USER}/.data/zinit/plugins/zsh-users---zsh-autosuggestions" rev-parse HEAD)" = "85919cd1ffa7d2d5412f6d3fe437ebdbeeec4fc5" \ + && test "$(git -C "/home/${USER}/.data/zinit/plugins/Aloxaf---fzf-tab" rev-parse HEAD)" = "24105b15714bfec37989ed5c5b6e60f572253019" \ + && test "$(git -C "/home/${USER}/.data/zinit/plugins/zdharma-continuum---fast-syntax-highlighting" rev-parse HEAD)" = "3d574ccf48804b10dca52625df13da5edae7f553" WORKDIR /app - #--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - +# PROD STAGE +#--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + FROM base AS prod +# Use production PHP configuration RUN cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" -# Copy zshrc first so we can pre-download plugins +# Copy production zshrc COPY --chown=${USER}:${USER} ./.zshrc.prod /home/${USER}/.zshrc -# Switch to the deploy user to install zinit and pre-download plugins +# Copy the pinned shell frameworks +COPY --from=shell-builder --chown=${USER}:${USER} /tmp/zinit /home/${USER}/.data/zinit/zinit.git +COPY --from=shell-builder --chown=${USER}:${USER} /tmp/oh-my-zsh /home/${USER}/.oh-my-zsh + +# Switch to the runtime user and pre-download pinned shell plugins USER ${USER} -# Install zinit and pre-download plugins -RUN ZINIT_HOME="/home/${USER}/.local/share/zinit" NO_EDIT=1 NO_TUTORIAL=1 \ - bash -c "$(curl --fail --show-error --silent --location https://raw.githubusercontent.com/zdharma-continuum/zinit/HEAD/scripts/install.sh)" \ - && zsh -i -c 'zinit self-update && exit 0' || true +RUN zsh -i -c 'exit 0' \ + && test "$(git -C "/home/${USER}/.data/zinit/plugins/zsh-users---zsh-autosuggestions" rev-parse HEAD)" = "85919cd1ffa7d2d5412f6d3fe437ebdbeeec4fc5" \ + && test "$(git -C "/home/${USER}/.data/zinit/plugins/zdharma-continuum---fast-syntax-highlighting" rev-parse HEAD)" = "3d574ccf48804b10dca52625df13da5edae7f553" -WORKDIR /app \ No newline at end of file +WORKDIR /app diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b09fc0a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Clovis Muneza + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c57129d --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# FrankenPHP Docker Images + +Custom [FrankenPHP](https://frankenphp.dev) Docker images with batteries included for Laravel development and production. + +## Quick Start + +```bash +# Development (includes xdebug, dev tools) +docker pull ghcr.io/prvious/frankenphp:php8.5-dev + +# Production +docker pull ghcr.io/prvious/frankenphp:php8.5 +``` + +## What's Included + +### Base (both dev & prod) + +- **PHP Extensions**: mysqli, pdo_mysql, pgsql, pdo_pgsql, bcmath, gd, imagick, imap, pcntl, zip, intl, exif, ftp, xml, sqlsrv, pdo_sqlsrv, sockets +- **Node.js**: v26 +- **Package Managers**: Composer, pnpm v11 +- **Database Clients**: PostgreSQL 17, MySQL +- **Image Tools**: jpegoptim, optipng, pngquant, gifsicle, avifenc, svgo, ffmpeg +- **Process Manager**: Supervisor +- **Shell**: Zsh with zinit + +### Dev Only + +- **PHP**: Xdebug +- **Tools**: GitHub CLI, OpenCode, htop, nano, fzf, zoxide, eza +- **Shell**: Starship prompt, syntax highlighting, autosuggestions, fzf-tab + +## Available Tags + +The entries below illustrate the dynamically selected PHP minor tag format: + +| Tag | Description | +|-----|-------------| +| `php8.5` | PHP 8.5 production | +| `php8.5-dev` | PHP 8.5 development | +| `php8.4` | PHP 8.4 production | +| `php8.4-dev` | PHP 8.4 development | +| `latest` | Latest supported PHP production | +| `latest-dev` | Latest supported PHP development | + +The workflow discovers and publishes the two newest stable PHP minor lines. Unqualified tags use Debian Trixie. For Debian Bookworm, insert `-bookworm` before the optional `-dev` suffix: `php8.5-bookworm`, `php8.5-bookworm-dev`, `latest-bookworm`, or `latest-bookworm-dev`. All images support `linux/amd64` and `linux/arm64`. + +## Usage with Laravel + +```yaml +# docker-compose.yml +services: + app: + image: ghcr.io/prvious/frankenphp:php8.5-dev + ports: + - "80:80" + - "443:443" + volumes: + - .:/app + working_dir: /app +``` + +### Laravel Aliases + +These aliases are available inside the container: + +```bash +pa # php artisan +pint # ./vendor/bin/pint +pest # ./vendor/bin/pest +stan # ./vendor/bin/phpstan +amf # php artisan migrate:fresh +amfs # php artisan migrate:fresh --seed +``` + +### Health Checks + +Built-in health check scripts for Laravel services: + +```bash +healthcheck-horizon # Check Laravel Horizon +healthcheck-octane # Check Laravel Octane +healthcheck-queue # Check queue workers +healthcheck-schedule # Check scheduler +``` + +## Building Locally + +```bash +# Build dev image +docker build --build-arg VERSION=8.5-trixie --target dev -t frankenphp:dev . + +# Build prod image +docker build --build-arg VERSION=8.5-trixie --target prod -t frankenphp:prod . + +# Build the default PHP 8.4/8.5 matrix +docker buildx bake + +# Build an explicit version set and label it with the current commit +PHP_VERSION=8.4.23,8.5.8 SHA="$(git rev-parse HEAD)" LATEST=8.5.8 docker buildx bake +``` + +## Configuration + +The container runs as user `deploy` (UID 1000). Key paths: + +- **App**: `/app` +- **pnpm store**: `/home/deploy/.pnpm-store` +- **Caddy data**: `/data/caddy` +- **Caddy config**: `/config/caddy` + +Environment variables: + +- `SERVER_NAME=:80` - Caddy server name +- `TZ=UTC` - Timezone + +## License + +[MIT](LICENSE) diff --git a/docker-bake.hcl b/docker-bake.hcl index 37e8e37..71c6e84 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -3,15 +3,18 @@ variable "IMAGE_NAME" { } variable "PHP_VERSION" { - description = "Comma-separated list of PHP versions to build, e.g. '8.1.0,8.2.0,8.3.0'." + description = "Comma-separated list of exact PHP versions to build, e.g. '8.4.23,8.5.8'." + default = "8.4.23,8.5.8" } variable "SHA" { - description = "The git commit SHA to use for the build." + description = "Git commit SHA for OCI revision labels; defaults to 'local' for local builds." + default = "local" } variable "LATEST" { description = "The latest PHP version to use for tagging the 'latest' tag." + default = "8.5.8" } function "clean_tag" { @@ -33,7 +36,7 @@ function "tag" { // Latest tags for the LATEST PHP version pv == LATEST ? ["${IMAGE_NAME}:latest-${os}${variant == "dev" ? "-dev" : ""}"] : [], // Semver tags with OS and variant (only if version is not empty and semver returns results) - [for v in (semver(version)) : "${IMAGE_NAME}:php${v}-${os}${variant == "dev" ? "-dev" : ""}"], + [for v in (semver(version)) : "${IMAGE_NAME}:php${v}-${os}${variant == "dev" ? "-dev" : ""}" if v != split(".", version)[0] || php_version == LATEST], ]) ])) } @@ -60,7 +63,7 @@ function "php_version" { function "_php_version" { params = [v, m] - result = "${m.major}.${m.minor}" == "8.4" ? [v, "${m.major}.${m.minor}", "${m.major}"] : [v, "${m.major}.${m.minor}"] + result = [v, "${m.major}.${m.minor}"] } target "default" { @@ -90,7 +93,7 @@ target "default" { } labels = { - "org.opencontainers.image.description" = variant == "dev" ? "FrankenPHP Docker images (${os}) with supervisor, fnm(node version manager), pnpm, sqlsrv, Xdebug, and a few other goodies." : "FrankenPHP Docker images (${os}) with supervisor, fnm(node version manager), pnpm, sqlsrv, and a few other goodies." + "org.opencontainers.image.description" = variant == "dev" ? "FrankenPHP Docker images (${os}) with supervisor, Node.js 26, pnpm, sqlsrv, Xdebug, and development tools." : "FrankenPHP Docker images (${os}) with supervisor, Node.js 26, pnpm, sqlsrv, and image tools." "org.opencontainers.image.created" = "${timestamp()}" "org.opencontainers.image.version" = variant == "dev" ? "${clean_tag(php_version)}-${os}-dev" : "${clean_tag(php_version)}-${os}" "org.opencontainers.image.revision" = SHA diff --git a/test.php b/test.php index a49f6fa..d62bcda 100644 --- a/test.php +++ b/test.php @@ -2,9 +2,10 @@ declare(strict_types=1); -const PRODUCTION_EXTENSIONS = ['mysqli', 'pdo_mysql', 'pgsql', 'pdo_pgsql', 'bcmath', 'gd', 'imagick', 'imap', 'pcntl', 'zip', 'intl', 'exif', 'ftp', 'xml', 'pdo_sqlsrv', 'sqlsrv', 'sockets']; +const NODE_MAJOR_VERSION = 26; +const PNPM_MAJOR_VERSION = 11; -const PRODUCTION_ONLY_EXTENSIONS = []; +const PRODUCTION_EXTENSIONS = ['mysqli', 'pdo_mysql', 'pgsql', 'pdo_pgsql', 'bcmath', 'gd', 'imagick', 'imap', 'pcntl', 'zip', 'intl', 'exif', 'ftp', 'xml', 'pdo_sqlsrv', 'sqlsrv', 'sockets']; const DEV_ONLY_EXTENSIONS = ['xdebug']; @@ -13,17 +14,40 @@ ...DEV_ONLY_EXTENSIONS, ]; -const PRODUCTION_BINARIES = ['php', 'composer', 'node', 'npm', 'pnpm', 'jpegoptim', 'optipng', 'pngquant', 'gifsicle', 'ffmpeg', 'svgo', 'avifenc', 'zsh']; +const PRODUCTION_BINARIES = ['php', 'composer', 'node', 'pnpm', 'psql', 'mysql', 'supervisord', 'jpegoptim', 'optipng', 'pngquant', 'gifsicle', 'ffmpeg', 'svgo', 'avifenc', 'zsh']; -const PRODUCTION_ONLY_BINARIES = []; - -const DEV_ONLY_BINARIES = ['gh', 'htop', 'nano', 'fzf', 'zoxide']; +const DEV_ONLY_BINARIES = ['gh', 'eza', 'htop', 'nano', 'fzf', 'zoxide', 'starship', 'opencode']; const DEV_BINARIES = [ ...PRODUCTION_BINARIES, ...DEV_ONLY_BINARIES, ]; +const PRODUCTION_BINARY_CHECKS = [ + 'composer' => 'composer --version', + 'psql' => 'psql --version', + 'mysql' => 'mysql --version', + 'supervisord' => 'supervisord --version', + 'jpegoptim' => 'jpegoptim --version', + 'optipng' => 'optipng -version', + 'pngquant' => 'pngquant --version', + 'gifsicle' => 'gifsicle --version', + 'svgo' => 'svgo --version', + 'avifenc' => 'avifenc --version', + 'zsh' => 'zsh --version', +]; + +const DEV_ONLY_BINARY_CHECKS = [ + 'gh' => 'gh --version', + 'eza' => 'eza --version', + 'htop' => 'htop --version', + 'nano' => 'nano --version', + 'fzf' => 'fzf --version', + 'zoxide' => 'zoxide --version', + 'starship' => 'starship --version', + 'opencode' => 'opencode --version', +]; + class Colors { // Control codes @@ -69,14 +93,6 @@ public function printHeader(string $emoji, string $title): void echo "{$envText} {$header}\n"; } - public function warn(string $message): void - { - $warnText = paint("[WARN]", Colors::BOLD . Colors::BRIGHT_YELLOW); - $messageText = paint($message, Colors::MAGENTA); - - echo "{$warnText} {$messageText}\n"; - } - public function check(string $name, bool $passed, ?string $hint = null): void { if ($passed) { @@ -113,6 +129,16 @@ public function commandExists(string $command): bool return is_string($output) && trim($output) !== ''; } + public function commandSucceeds(string $command): bool + { + $output = []; + $exitCode = 1; + + @exec("{$command} >/dev/null 2>&1", $output, $exitCode); + + return $exitCode === 0; + } + public function finish(): never { if ($this->failures > 0) { @@ -164,15 +190,11 @@ function extensions(Runner $runner): void if ($runner->environment === 'production') { foreach (DEV_ONLY_EXTENSIONS as $extension) { - if (extension_loaded($extension)) { - $runner->warn("extension:{$extension} present (unexpected in production)"); - } - } - } else { - foreach (PRODUCTION_ONLY_EXTENSIONS as $extension) { - if (extension_loaded($extension)) { - $runner->warn("extension:{$extension} present (unexpected in dev)"); - } + $runner->check( + "forbidden extension:{$extension}", + !extension_loaded($extension), + 'must not be present in production' + ); } } } @@ -187,17 +209,25 @@ function binaries(Runner $runner): void $runner->check("binary:{$binary}", $runner->commandExists($binary)); } + $binaryChecks = $runner->environment === 'dev' + ? [...PRODUCTION_BINARY_CHECKS, ...DEV_ONLY_BINARY_CHECKS] + : PRODUCTION_BINARY_CHECKS; + + foreach ($binaryChecks as $binary => $command) { + $runner->check( + "binary executable:{$binary}", + $runner->commandSucceeds($command), + "command failed: {$command}" + ); + } + if ($runner->environment === 'production') { foreach (DEV_ONLY_BINARIES as $binary) { - if ($runner->commandExists($binary)) { - $runner->warn("binary:{$binary} present (unexpected in production)"); - } - } - } else { - foreach (PRODUCTION_ONLY_BINARIES as $binary) { - if ($runner->commandExists($binary)) { - $runner->warn("binary:{$binary} present (unexpected in dev)"); - } + $runner->check( + "forbidden binary:{$binary}", + !$runner->commandExists($binary), + 'must not be present in production' + ); } } } @@ -209,14 +239,45 @@ function sanity(Runner $runner): void $modules = @shell_exec('php -m 2>/dev/null'); $runner->check('php-cli works', is_string($modules) && trim($modules) !== ''); - if ($runner->environment === 'dev') { - $pnpmStorePath = trim(@shell_exec('pnpm store path 2>/dev/null') ?? ''); - $expectedPath = '/home/deploy/.pnpm-store'; - $runner->check( - 'pnpm store path', - str_starts_with($pnpmStorePath, $expectedPath), - "expected {$expectedPath}, got {$pnpmStorePath}" - ); + $nodeVersion = trim(@shell_exec('node --version 2>/dev/null') ?? ''); + $runner->check( + 'node major version', + str_starts_with($nodeVersion, 'v' . NODE_MAJOR_VERSION . '.'), + 'expected v' . NODE_MAJOR_VERSION . ".x, got {$nodeVersion}" + ); + + $pnpmVersion = trim(@shell_exec('pnpm --version 2>/dev/null') ?? ''); + $runner->check( + 'pnpm major version', + str_starts_with($pnpmVersion, PNPM_MAJOR_VERSION . '.'), + 'expected ' . PNPM_MAJOR_VERSION . ".x, got {$pnpmVersion}" + ); + + $home = getenv('HOME') ?: '/home/deploy'; + $pnpmStorePath = trim(@shell_exec('pnpm store path 2>/dev/null') ?? ''); + $expectedPath = "{$home}/.pnpm-store"; + $runner->check( + 'pnpm store path', + str_starts_with($pnpmStorePath, $expectedPath), + "expected {$expectedPath}, got {$pnpmStorePath}" + ); + + $psyshPath = "{$home}/.config/psysh"; + $runner->check( + 'psysh config directory', + is_dir($psyshPath) && is_writable($psyshPath), + "expected writable directory {$psyshPath}" + ); + + $appProbe = @tempnam('/app', 'frankenphp-test-'); + $runner->check( + 'app directory writable', + is_string($appProbe), + 'expected the runtime user to write to /app' + ); + + if (is_string($appProbe)) { + @unlink($appProbe); } }