Skip to content

Commit 07f2a6d

Browse files
chapterjasonclaude
andcommitted
Add PHP workspace stack
New stack (ghcr.io/sourecode/coder-workspace:php) layered on base: - Sury repo (packages.sury.org/php) with PHP 8.5 default + dev extension set (cli common curl mbstring xml intl zip gd bcmath opcache mysql pgsql sqlite3 redis xdebug). Xdebug is baked in but its default mode is off — zero overhead until XDEBUG_MODE is flipped. - pvm (PHP version manager) — user-level switcher that flips symlinks in \$HOME/.local/bin. No sudo for use/ls/current; install/remove touch apt. 'latest' and 'lts' resolve against php.net's supported-versions list (php.net has no official LTS — our convention is "oldest branch still in security support"). Falls back to Sury-local if php.net is unreachable. - Composer via the official phar installer with signature check. - symfony-cli from GitHub releases. - FrankenPHP from GitHub releases. Each installer gets its own narrow bind mount in src/php/Dockerfile so per-tool bumps only invalidate one layer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6d688fa commit 07f2a6d

8 files changed

Lines changed: 324 additions & 1 deletion

File tree

.github/workflows/publish-workspaces.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ jobs:
7171
strategy:
7272
fail-fast: false
7373
matrix:
74-
stack: [node, cpp]
74+
stack: [node, cpp, php]
7575
steps:
7676
- uses: actions/checkout@v6
7777

main.tf

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ locals {
2525
base = "ghcr.io/sourecode/coder-workspace:base"
2626
node = "ghcr.io/sourecode/coder-workspace:node"
2727
cpp = "ghcr.io/sourecode/coder-workspace:cpp"
28+
php = "ghcr.io/sourecode/coder-workspace:php"
2829
}
2930

3031
git_author_name = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
@@ -51,6 +52,10 @@ data "coder_parameter" "workspace_image" {
5152
name = "cpp — base + llvm + cmake + sccache"
5253
value = "cpp"
5354
}
55+
option {
56+
name = "php — base + Sury PHP (default 8.5) + composer + symfony-cli + frankenphp + pvm"
57+
value = "php"
58+
}
5459
}
5560

5661
data "coder_parameter" "repo_url" {

scripts/composer/install.sh

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/env bash
2+
# Composer installer. Runs as the workspace user — drops the phar into
3+
# $HOME/.local/bin (on PATH via pipx ensurepath in base). Verified against
4+
# the SHA-384 that the official Composer installer script publishes.
5+
# https://getcomposer.org/
6+
set -e
7+
8+
COMPOSER_VERSION="${VERSION:-}"
9+
INSTALL_DIR="$HOME/.local/bin"
10+
mkdir -p "$INSTALL_DIR"
11+
12+
if ! command -v php >/dev/null 2>&1; then
13+
echo "composer: php not on PATH. scripts/php/install.sh must run before scripts/composer/install.sh." >&2
14+
exit 1
15+
fi
16+
17+
tmp=$(mktemp -d)
18+
trap 'rm -rf "$tmp"' EXIT
19+
20+
curl -fsSL https://getcomposer.org/installer -o "$tmp/composer-setup.php"
21+
expected=$(curl -fsSL https://composer.github.io/installer.sig)
22+
actual=$(php -r "echo hash_file('sha384', '$tmp/composer-setup.php');")
23+
if [ "$expected" != "$actual" ]; then
24+
echo "composer: installer signature mismatch (expected=$expected actual=$actual)." >&2
25+
exit 1
26+
fi
27+
28+
args=(--quiet --install-dir="$INSTALL_DIR" --filename=composer)
29+
[ -n "$COMPOSER_VERSION" ] && args+=(--version="$COMPOSER_VERSION")
30+
php "$tmp/composer-setup.php" "${args[@]}"
31+
32+
if ! "$INSTALL_DIR/composer" --version >/dev/null 2>&1; then
33+
echo "composer: binary not runnable after install." >&2
34+
exit 1
35+
fi

scripts/frankenphp/install.sh

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#!/usr/bin/env bash
2+
# FrankenPHP installer. Runs as the workspace user — single static binary
3+
# from GitHub releases into $HOME/.local/bin. FrankenPHP is a modern PHP
4+
# app server (Caddy + PHP embedded).
5+
# https://github.com/dunglas/frankenphp
6+
set -e
7+
8+
FP_VERSION_OPT="${VERSION:-latest}"
9+
INSTALL_DIR="$HOME/.local/bin"
10+
mkdir -p "$INSTALL_DIR"
11+
12+
arch=$(uname -m)
13+
case "$arch" in
14+
x86_64) asset_arch=x86_64 ;;
15+
aarch64) asset_arch=aarch64 ;;
16+
*) echo "frankenphp: unsupported arch '$arch'." >&2; exit 1 ;;
17+
esac
18+
19+
if [ "$FP_VERSION_OPT" = "latest" ]; then
20+
FP_VERSION="$(curl -fsSL https://api.github.com/repos/dunglas/frankenphp/releases/latest | jq -r .tag_name)"
21+
else
22+
FP_VERSION="$FP_VERSION_OPT"
23+
fi
24+
FP_VERSION="${FP_VERSION#v}"
25+
if [ -z "$FP_VERSION" ] || [ "$FP_VERSION" = "null" ]; then
26+
echo "frankenphp: failed to resolve release version (got '$FP_VERSION_OPT')." >&2
27+
exit 1
28+
fi
29+
30+
curl -fsSL "https://github.com/dunglas/frankenphp/releases/download/v${FP_VERSION}/frankenphp-linux-${asset_arch}" \
31+
-o "$INSTALL_DIR/frankenphp"
32+
chmod 0755 "$INSTALL_DIR/frankenphp"
33+
34+
if ! "$INSTALL_DIR/frankenphp" version >/dev/null 2>&1; then
35+
echo "frankenphp: binary not runnable after install." >&2
36+
exit 1
37+
fi

scripts/php/install.sh

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#!/usr/bin/env bash
2+
# PHP installer. Runs as root. Adds Sury's PHP repo (packages.sury.org/php),
3+
# installs the default version with a dev-friendly extension set, and drops
4+
# /usr/local/bin/pvm — a user-level switcher for swapping active PHP versions
5+
# via symlinks under $HOME/.local/bin (no sudo needed to switch).
6+
# `pvm install <ver>` adds more versions from Sury on demand.
7+
# https://deb.sury.org/
8+
set -e
9+
10+
PHP_DEFAULT_VERSION="${VERSION:-8.5}"
11+
# Extensions installed for every version (Sury naming, prefixed at use-site).
12+
# `common` pulls pdo/phar; `mysql`/`pgsql`/`sqlite3` include their pdo drivers.
13+
PHP_EXT_SET="cli common curl mbstring xml intl zip gd bcmath opcache mysql pgsql sqlite3 redis xdebug"
14+
15+
install -m 0755 -d /etc/apt/keyrings
16+
curl -fsSL https://packages.sury.org/php/apt.gpg \
17+
| gpg --dearmor -o /etc/apt/keyrings/sury-php.gpg
18+
chmod a+r /etc/apt/keyrings/sury-php.gpg
19+
echo "deb [signed-by=/etc/apt/keyrings/sury-php.gpg] https://packages.sury.org/php/ $(. /etc/os-release && echo $VERSION_CODENAME) main" \
20+
> /etc/apt/sources.list.d/sury-php.list
21+
22+
pkgs=""
23+
for ext in $PHP_EXT_SET; do
24+
pkgs="$pkgs php${PHP_DEFAULT_VERSION}-${ext}"
25+
done
26+
27+
apt-get update
28+
# shellcheck disable=SC2086
29+
apt-get install -y --no-install-recommends --no-install-suggests $pkgs
30+
rm -rf /var/lib/apt/lists/*
31+
32+
# pvm — user-level PHP version switcher. Lives next to this installer; see
33+
# scripts/php/pvm for the script body.
34+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
35+
install -m 0755 "$script_dir/pvm" /usr/local/bin/pvm
36+
37+
if ! command -v php >/dev/null 2>&1; then
38+
echo "php: /usr/bin/php not on PATH after install." >&2
39+
exit 1
40+
fi

scripts/php/pvm

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#!/usr/bin/env bash
2+
# pvm — PHP version manager. Switches which PHP the user invokes as `php`
3+
# by flipping symlinks in $HOME/.local/bin (already on PATH via base's
4+
# pipx ensurepath). No sudo for `use` / `ls` / `current`; only `install` /
5+
# `remove` touch apt.
6+
#
7+
# Aliases 'latest' / 'lts' resolve against php.net's supported-versions
8+
# list; falls back to locally-packaged Sury versions if php.net is
9+
# unreachable. PHP has no official LTS — 'lts' is our convention for
10+
# "oldest branch still in security support" (longest-lived pin).
11+
set -e
12+
13+
EXT_SET="cli common curl mbstring xml intl zip gd bcmath opcache mysql pgsql sqlite3 redis xdebug"
14+
USER_BIN="$HOME/.local/bin"
15+
TOOLS=(php phpize php-config)
16+
17+
usage() {
18+
cat <<EOF
19+
pvm — PHP version manager (Sury-backed).
20+
21+
Usage:
22+
pvm Show active PHP version.
23+
pvm ls List installed versions (* = active).
24+
pvm ls-remote List versions packaged by Sury.
25+
pvm use <ver> Link \$HOME/.local/bin/{php,phpize,php-config} → phpX.Y.
26+
pvm install <ver> apt-install phpX.Y-* from Sury (uses sudo).
27+
pvm remove <ver> apt-purge phpX.Y-* (uses sudo).
28+
29+
Aliases: 'latest' = newest supported branch (php.net); 'lts' = oldest branch
30+
still in security support (php.net). Falls back to locally-packaged Sury
31+
versions if php.net is unreachable.
32+
EOF
33+
}
34+
35+
sury_versions() {
36+
apt-cache search --names-only '^php[0-9]+\.[0-9]+-cli$' 2>/dev/null \
37+
| awk '{print $1}' | sed -n 's/^php\([0-9.]*\)-cli$/\1/p' | sort -V
38+
}
39+
40+
# php.net's ?json endpoint returns supported_versions ordered oldest → newest.
41+
# Only major=8 today; update when PHP 9 ships.
42+
supported_versions() {
43+
curl -fsSL --max-time 5 'https://www.php.net/releases/index.php?json&version=8' 2>/dev/null \
44+
| jq -r '.supported_versions[]?' 2>/dev/null
45+
}
46+
47+
resolve_alias() {
48+
local list
49+
case "$1" in
50+
latest)
51+
list=$(supported_versions); [ -z "$list" ] && list=$(sury_versions)
52+
echo "$list" | tail -n 1
53+
;;
54+
lts)
55+
list=$(supported_versions)
56+
if [ -n "$list" ]; then
57+
echo "$list" | head -n 1
58+
else
59+
list=$(sury_versions)
60+
if [ "$(echo "$list" | wc -l)" -lt 2 ]; then
61+
echo "$list" | tail -n 1
62+
else
63+
echo "$list" | tail -n 2 | head -n 1
64+
fi
65+
fi
66+
;;
67+
*) echo "$1" ;;
68+
esac
69+
}
70+
71+
installed_versions() {
72+
compgen -G '/usr/bin/php[0-9].[0-9]' 2>/dev/null \
73+
| sed -n 's|/usr/bin/php||p' | sort -V
74+
}
75+
76+
active_version() {
77+
local link
78+
link=$(readlink "$USER_BIN/php" 2>/dev/null || true)
79+
if [ -n "$link" ]; then
80+
basename "$link" | sed 's/^php//'
81+
elif command -v php >/dev/null 2>&1; then
82+
php -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;' 2>/dev/null || true
83+
fi
84+
}
85+
86+
cmd_ls() {
87+
local current
88+
current=$(active_version)
89+
for v in $(installed_versions); do
90+
if [ "$v" = "$current" ]; then echo "* $v"; else echo " $v"; fi
91+
done
92+
}
93+
94+
cmd_ls_remote() { sury_versions; }
95+
96+
cmd_use() {
97+
local raw="${1:-}" ver
98+
[ -n "$raw" ] || { usage; exit 1; }
99+
ver=$(resolve_alias "$raw")
100+
[ -x "/usr/bin/php$ver" ] || { echo "pvm: PHP $ver not installed. Try: pvm install $raw" >&2; exit 1; }
101+
mkdir -p "$USER_BIN"
102+
for t in "${TOOLS[@]}"; do
103+
if [ -x "/usr/bin/${t}${ver}" ]; then
104+
ln -sfn "/usr/bin/${t}${ver}" "$USER_BIN/$t"
105+
fi
106+
done
107+
echo "PHP $ver active ($USER_BIN/php → /usr/bin/php$ver)."
108+
}
109+
110+
cmd_install() {
111+
local raw="${1:-}" ver
112+
[ -n "$raw" ] || { usage; exit 1; }
113+
ver=$(resolve_alias "$raw")
114+
local pkgs=""
115+
for ext in $EXT_SET; do pkgs="$pkgs php${ver}-${ext}"; done
116+
sudo apt-get update
117+
# shellcheck disable=SC2086
118+
sudo apt-get install -y --no-install-recommends --no-install-suggests $pkgs
119+
echo "PHP $ver installed. Activate with: pvm use $ver"
120+
}
121+
122+
cmd_remove() {
123+
local raw="${1:-}" ver
124+
[ -n "$raw" ] || { usage; exit 1; }
125+
ver=$(resolve_alias "$raw")
126+
sudo apt-get purge -y "php${ver}*"
127+
sudo apt-get autoremove -y
128+
}
129+
130+
cmd_current() {
131+
local v
132+
v=$(active_version)
133+
[ -n "$v" ] && echo "$v" || { echo "pvm: no PHP active" >&2; exit 1; }
134+
}
135+
136+
case "${1:-current}" in
137+
ls|list) cmd_ls ;;
138+
ls-remote) cmd_ls_remote ;;
139+
use) shift; cmd_use "$@" ;;
140+
install) shift; cmd_install "$@" ;;
141+
remove|uninstall) shift; cmd_remove "$@" ;;
142+
current|'') cmd_current ;;
143+
-h|--help|help) usage ;;
144+
*) usage; exit 1 ;;
145+
esac

scripts/symfony-cli/install.sh

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/usr/bin/env bash
2+
# symfony-cli installer. Runs as the workspace user — prebuilt tarball from
3+
# GitHub releases into $HOME/.local/bin.
4+
# https://github.com/symfony-cli/symfony-cli
5+
set -e
6+
7+
SF_VERSION_OPT="${VERSION:-latest}"
8+
INSTALL_DIR="$HOME/.local/bin"
9+
mkdir -p "$INSTALL_DIR"
10+
11+
arch=$(dpkg --print-architecture)
12+
case "$arch" in
13+
amd64) asset_arch=amd64 ;;
14+
arm64) asset_arch=arm64 ;;
15+
*) echo "symfony-cli: unsupported arch '$arch'." >&2; exit 1 ;;
16+
esac
17+
18+
if [ "$SF_VERSION_OPT" = "latest" ]; then
19+
SF_VERSION="$(curl -fsSL https://api.github.com/repos/symfony-cli/symfony-cli/releases/latest | jq -r .tag_name)"
20+
else
21+
SF_VERSION="$SF_VERSION_OPT"
22+
fi
23+
SF_VERSION="${SF_VERSION#v}"
24+
if [ -z "$SF_VERSION" ] || [ "$SF_VERSION" = "null" ]; then
25+
echo "symfony-cli: failed to resolve release version (got '$SF_VERSION_OPT')." >&2
26+
exit 1
27+
fi
28+
29+
tmp=$(mktemp -d)
30+
trap 'rm -rf "$tmp"' EXIT
31+
32+
curl -fsSL "https://github.com/symfony-cli/symfony-cli/releases/download/v${SF_VERSION}/symfony-cli_linux_${asset_arch}.tar.gz" \
33+
| tar -xz -C "$tmp"
34+
install -m 0755 "$tmp/symfony" "$INSTALL_DIR/symfony"
35+
36+
if ! "$INSTALL_DIR/symfony" version >/dev/null 2>&1; then
37+
echo "symfony-cli: binary not runnable after install." >&2
38+
exit 1
39+
fi

src/php/Dockerfile

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# syntax=docker/dockerfile:1
2+
3+
ARG BASE_IMAGE=ghcr.io/sourecode/coder-workspace:base
4+
FROM ${BASE_IMAGE}
5+
6+
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
7+
ENV DEBIAN_FRONTEND=noninteractive
8+
9+
# System-wide: Sury repo + default PHP + extension set + pvm switcher.
10+
RUN --mount=type=bind,source=scripts/php,target=/scripts/php \
11+
bash /scripts/php/install.sh
12+
13+
# Per-user tooling: composer phar, symfony-cli, frankenphp — all land in
14+
# $HOME/.local/bin, on PATH via base's pipx ensurepath.
15+
USER ${_REMOTE_USER}
16+
RUN --mount=type=bind,source=scripts/composer,target=/scripts/composer \
17+
bash /scripts/composer/install.sh
18+
RUN --mount=type=bind,source=scripts/symfony-cli,target=/scripts/symfony-cli \
19+
bash /scripts/symfony-cli/install.sh
20+
RUN --mount=type=bind,source=scripts/frankenphp,target=/scripts/frankenphp \
21+
bash /scripts/frankenphp/install.sh
22+
USER root

0 commit comments

Comments
 (0)