Skip to content

Commit 46b916f

Browse files
committed
Initial commit: PHP CLI dev image, Alpine-based, 8.2/8.3/8.4/8.5 matrix, GHCR release workflow
0 parents  commit 46b916f

9 files changed

Lines changed: 565 additions & 0 deletions

File tree

.claude/settings.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(./build-local.sh *)",
5+
"Bash(./release.sh *)"
6+
]
7+
}
8+
}

.github/workflows/release.yml

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*.*.*'
7+
8+
env:
9+
IMAGE: ghcr.io/scalecommerce/docker-php-cli
10+
11+
jobs:
12+
build:
13+
runs-on: ubuntu-latest
14+
permissions:
15+
contents: write
16+
packages: write
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- name: Derive versions from tag
21+
id: v
22+
run: |
23+
FULL="${GITHUB_REF_NAME#v}"
24+
if ! [[ "$FULL" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
25+
echo "::error::tag must be vX.Y.Z (got $GITHUB_REF_NAME)"
26+
exit 1
27+
fi
28+
MAJOR="${FULL%.*}"
29+
# PHP major -> Alpine branch. Keep in sync with build-local.sh.
30+
case "$MAJOR" in
31+
8.2) ALPINE=3.22 ;;
32+
8.3|8.4|8.5) ALPINE=3.23 ;;
33+
*) echo "::error::unsupported PHP version $MAJOR"; exit 1 ;;
34+
esac
35+
{
36+
echo "full=$FULL"
37+
echo "major=$MAJOR"
38+
echo "alpine=$ALPINE"
39+
} >> "$GITHUB_OUTPUT"
40+
41+
- uses: docker/setup-qemu-action@v3
42+
- uses: docker/setup-buildx-action@v3
43+
44+
- uses: docker/login-action@v3
45+
with:
46+
registry: ghcr.io
47+
username: ${{ github.actor }}
48+
password: ${{ secrets.GITHUB_TOKEN }}
49+
50+
- name: Build and push (multi-arch)
51+
uses: docker/build-push-action@v6
52+
with:
53+
context: .
54+
platforms: linux/amd64,linux/arm64
55+
push: true
56+
build-args: |
57+
ALPINE_VERSION=${{ steps.v.outputs.alpine }}
58+
PHP_VERSION=${{ steps.v.outputs.major }}
59+
tags: |
60+
${{ env.IMAGE }}:${{ steps.v.outputs.full }}
61+
${{ env.IMAGE }}:${{ steps.v.outputs.major }}
62+
labels: |
63+
org.opencontainers.image.version=${{ steps.v.outputs.full }}
64+
org.opencontainers.image.source=https://github.com/${{ github.repository }}
65+
66+
- name: Verify published image contains the tagged PHP version
67+
run: |
68+
docker pull "$IMAGE:${{ steps.v.outputs.full }}"
69+
ACTUAL=$(docker run --rm "$IMAGE:${{ steps.v.outputs.full }}" php -r 'echo PHP_VERSION;')
70+
if [[ "$ACTUAL" != "${{ steps.v.outputs.full }}" ]]; then
71+
echo "::error::tag is ${{ steps.v.outputs.full }} but image contains PHP $ACTUAL — Alpine bumped the patch between your local build and CI. Delete this tag and re-release with the new version."
72+
exit 1
73+
fi
74+
75+
- name: Extract versions.txt and extensions.txt
76+
id: info
77+
run: |
78+
{
79+
echo 'versions<<VERSIONS_EOF'
80+
docker run --rm "$IMAGE:${{ steps.v.outputs.full }}" cat /opt/versions.txt
81+
echo 'VERSIONS_EOF'
82+
} >> "$GITHUB_OUTPUT"
83+
{
84+
echo 'extensions<<EXTENSIONS_EOF'
85+
docker run --rm "$IMAGE:${{ steps.v.outputs.full }}" cat /opt/extensions.txt
86+
echo 'EXTENSIONS_EOF'
87+
} >> "$GITHUB_OUTPUT"
88+
89+
- name: Build release body
90+
env:
91+
VERSION: ${{ steps.v.outputs.full }}
92+
MAJOR: ${{ steps.v.outputs.major }}
93+
VERSIONS_TXT: ${{ steps.info.outputs.versions }}
94+
EXTENSIONS_TXT: ${{ steps.info.outputs.extensions }}
95+
run: |
96+
{
97+
echo '## Pull this version'
98+
echo ''
99+
echo '```'
100+
echo "docker pull $IMAGE:$VERSION"
101+
echo "docker pull $IMAGE:$MAJOR"
102+
echo '```'
103+
echo ''
104+
echo '## Versions'
105+
echo ''
106+
echo '```'
107+
echo "$VERSIONS_TXT"
108+
echo '```'
109+
echo ''
110+
echo '## PHP extensions'
111+
echo ''
112+
echo '```'
113+
echo "$EXTENSIONS_TXT"
114+
echo '```'
115+
} > release-body.md
116+
cat release-body.md
117+
118+
- name: Create GitHub release
119+
uses: softprops/action-gh-release@v2
120+
with:
121+
body_path: release-body.md

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.build-verified-*
2+
.claude/settings.local.json
3+
.DS_Store

BUILD.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Release a new version
2+
3+
Two steps.
4+
5+
### 1. Build locally
6+
7+
```
8+
./build-local.sh 8.4
9+
```
10+
11+
Builds the image for PHP 8.4 on host arch, pulling whatever patch Alpine currently ships as `php84`. Prints the full PHP version. If the version shown is what you want to publish, continue.
12+
13+
### 2. Release
14+
15+
```
16+
./release.sh 8.4.12 # tag v8.4.12 and push — triggers CI
17+
./release.sh 8.4.12 --no-push # tag but don't push
18+
```
19+
20+
`release.sh` checks that `build-local.sh` was run against this exact version (via the `.build-verified-8.4` marker), then tags `v8.4.12` and pushes. The tag push triggers `.github/workflows/release.yml`, which:
21+
22+
1. Builds multi-arch (amd64/arm64) with matching Alpine + PHP build args.
23+
2. Pushes to `ghcr.io/scalecommerce/docker-php-cli:8.4.12` (immutable) and `ghcr.io/scalecommerce/docker-php-cli:8.4` (rolling).
24+
3. Pulls the published image and verifies it actually contains PHP 8.4.12 — guards against Alpine bumping the patch between your local build and the CI run.
25+
4. Creates a GitHub Release whose body contains `/opt/versions.txt` and `/opt/extensions.txt`.
26+
27+
### Preconditions for `release.sh`
28+
29+
* Working tree clean, on `main`, no unpushed commits.
30+
* `./build-local.sh <major>` was run first.
31+
* The version you're releasing matches what the local build produced. If Alpine bumped the patch while you were preparing the release, the check fires — re-run `build-local.sh` and release the new version instead.
32+
33+
## Alpine → PHP version mapping
34+
35+
| PHP | Alpine |
36+
| --- | ------ |
37+
| 8.2 | 3.22 |
38+
| 8.3 | 3.23 |
39+
| 8.4 | 3.23 |
40+
| 8.5 | 3.23 |
41+
42+
Kept in two places that must stay in sync: `build-local.sh` and `.github/workflows/release.yml`. If Alpine's PHP packaging moves, update both.
43+
44+
## Manual multi-arch build (fallback)
45+
46+
Only use this if CI is broken. You'll need a GitHub PAT with `write:packages`:
47+
48+
```
49+
echo $GH_PAT | docker login ghcr.io -u <github-user> --password-stdin
50+
docker buildx create --name multiplatform-builder --use # first time only
51+
docker buildx build --push --platform linux/amd64,linux/arm64 \
52+
--build-arg ALPINE_VERSION=3.23 --build-arg PHP_VERSION=8.4 \
53+
-t ghcr.io/scalecommerce/docker-php-cli:8.4.12 \
54+
-t ghcr.io/scalecommerce/docker-php-cli:8.4 .
55+
```

CLAUDE.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## About this file
6+
7+
Kept minimal on purpose. Don't add anything an agent would find by reading the Dockerfile, scripts, or README in 30 seconds — those are authoritative. Every line here biases every future response; when updating, ask "would removing this cause a mistake?"
8+
9+
## Project
10+
11+
Single parametric Dockerfile → minimal Alpine-based PHP CLI images for 8.2/8.3/8.4/8.5, published to `ghcr.io/scalecommerce/docker-php-cli`. No FPM, no nginx — that's a separate image.
12+
13+
## Critical: PHP → Alpine mapping is duplicated
14+
15+
```
16+
8.2 → alpine 3.22
17+
8.3, 8.4, 8.5 → alpine 3.23
18+
```
19+
20+
Lives as a `case` statement in BOTH `build-local.sh` AND `.github/workflows/release.yml`. If you change one, change the other — local and CI builds diverge silently otherwise.
21+
22+
## Release flow (non-obvious invariants)
23+
24+
Two steps: `./build-local.sh <major>` then `./release.sh <full-version>`. `build-local.sh` writes `.build-verified-<major>` (image SHA + Dockerfile content hash + resolved full PHP version). `release.sh` refuses to tag unless the requested version exactly matches what the local build produced. This is deliberate: if Alpine bumped the PHP patch between the two steps, you must re-run `build-local.sh` and release the *new* version. CI re-verifies post-push against the tag for the same reason.
25+
26+
Each release pushes `X.Y.Z` (immutable) + `X.Y` (rolling). **No `latest` tag — deliberate.** Don't add one without discussing it.
27+
28+
## Dockerfile quirks
29+
30+
- Extension list lives in the `EXTENSIONS` variable inside the RUN block. A per-package probe (`apk search -q -x`) silently filters missing packages and logs a `Note:` line. Alpine's PHP packaging isn't uniform across majors — e.g. `php85-opcache` isn't a separate package (opcache is compiled into the `php85` core). If you add an extension and it doesn't end up in the image, check the build log for the skip.
31+
- `/etc/php/conf.d/zz-defaults.ini` sets `memory_limit=-1`. The `zz-` prefix makes it load last; user-mounted overrides need a later prefix (e.g. `zzz-`) to win.
32+
33+
## Maintenance: check for newer Alpine/PHP before each release
34+
35+
Three drift patterns. Run these checks before cutting a release (or periodically — Alpine pushes PHP patches regularly).
36+
37+
**1. Patch bump in our current Alpine+PHP combo.** Alpine's `phpXY` package moves from e.g. 8.4.20 → 8.4.21 without any change on our side. Just run `./build-local.sh <major>` and compare the printed full version to the latest published tag. If different, release the new version.
38+
39+
**2. Older PHP major becoming available on newer Alpine.** 8.2 currently pins us to Alpine 3.22. When Alpine 3.23 (or newer) eventually packages `php82`, we can consolidate. Check:
40+
41+
```
42+
docker run --rm alpine:3.23 sh -c 'apk update -q && apk list php82 2>/dev/null'
43+
```
44+
45+
Non-empty output → update the `case` in both `build-local.sh` and `release.yml` to use the newer Alpine for 8.2, rebuild, release.
46+
47+
**3. New Alpine stable (e.g. 3.24 ships).** Re-run the probe above for each phpXY we care about against the new Alpine. If every active major has a package, migrate all mappings to the new Alpine, rebuild, release each major.
48+
49+
## When adding a new PHP major (e.g. 8.6)
50+
51+
Update the `case` in both `build-local.sh` AND `release.yml`. Confirm `phpXY` packages exist on the Alpine you pick (`docker run --rm alpine:X.Y sh -c 'apk update -q && apk list phpXY 2>/dev/null'`). Run `./build-local.sh X.Y` and inspect the `Note:` skips — if a critical extension is missing, decide whether to wait for Alpine to package it or adjust the extension list.

Dockerfile

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
ARG ALPINE_VERSION=3.23
2+
ARG PHP_VERSION=8.4
3+
FROM alpine:${ALPINE_VERSION}
4+
5+
ARG ALPINE_VERSION
6+
ARG PHP_VERSION
7+
8+
LABEL org.opencontainers.image.source="https://github.com/ScaleCommerce/docker-php-cli" \
9+
org.opencontainers.image.description="Minimal Alpine-based PHP CLI image for PHP project development" \
10+
org.opencontainers.image.licenses="MIT"
11+
12+
ENV PATH=/opt/:$PATH \
13+
COMPOSER_ALLOW_SUPERUSER=1
14+
15+
RUN set -eux; \
16+
PHP=$(echo "$PHP_VERSION" | tr -d '.'); \
17+
EXTENSIONS=" \
18+
bcmath bz2 calendar ctype curl dom exif ffi fileinfo ftp \
19+
gd gettext iconv intl mbstring mysqli mysqlnd opcache openssl \
20+
pcntl pdo pdo_mysql pdo_pgsql pdo_sqlite phar posix session \
21+
shmop simplexml soap sockets sodium sqlite3 sysvmsg sysvsem \
22+
sysvshm tokenizer xml xmlreader xmlwriter xsl zip \
23+
pecl-amqp pecl-apcu pecl-igbinary pecl-memcached pecl-msgpack \
24+
pecl-redis pecl-yaml pecl-zstd"; \
25+
apk update; \
26+
AVAIL=""; SKIPPED=""; \
27+
for ext in $EXTENSIONS; do \
28+
pkg="php${PHP}-${ext}"; \
29+
if apk search -q -x "$pkg" | grep -q .; then \
30+
AVAIL="$AVAIL $pkg"; \
31+
else \
32+
SKIPPED="$SKIPPED $pkg"; \
33+
fi; \
34+
done; \
35+
if [ -n "$SKIPPED" ]; then \
36+
echo "Note: the following packages are unavailable on alpine:${ALPINE_VERSION:-?} and will be skipped:$SKIPPED" >&2; \
37+
fi; \
38+
apk add --no-cache \
39+
bash git unzip curl make \
40+
nodejs npm \
41+
composer \
42+
php${PHP} \
43+
$AVAIL; \
44+
rm -rf /var/cache/apk/*; \
45+
ln -sf /usr/bin/php${PHP} /usr/bin/php; \
46+
ln -sfn /etc/php${PHP} /etc/php; \
47+
printf 'memory_limit=-1\n' > /etc/php/conf.d/zz-defaults.ini; \
48+
npm install -g pnpm; \
49+
npm cache clean --force; \
50+
mkdir -p /opt; \
51+
. /etc/os-release; \
52+
PHP_FULL_VER=$(php -r 'echo PHP_VERSION;'); \
53+
NODE_VER=$(node -v); \
54+
NPM_VER=$(npm -v); \
55+
PNPM_VER=$(pnpm -v); \
56+
COMPOSER_VER=$(composer --version --no-ansi 2>&1 | head -1); \
57+
{ \
58+
echo "$PRETTY_NAME ($(cat /etc/alpine-release))"; \
59+
echo "PHP version is $PHP_FULL_VER"; \
60+
echo "Node.js version is $NODE_VER"; \
61+
echo "npm version is $NPM_VER"; \
62+
echo "pnpm version is $PNPM_VER"; \
63+
echo "$COMPOSER_VER"; \
64+
echo ""; \
65+
} > /opt/versions.txt; \
66+
php -m > /opt/extensions.txt; \
67+
echo "PATH=/opt/:\$PATH" >> /root/.profile; \
68+
echo "cat /opt/versions.txt" >> /root/.profile
69+
70+
WORKDIR /app
71+
72+
CMD ["/bin/bash", "-l"]

0 commit comments

Comments
 (0)