diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml
new file mode 100644
index 0000000..e9aeec4
--- /dev/null
+++ b/.github/workflows/build-images.yml
@@ -0,0 +1,176 @@
+name: Build and push images
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - '*/*/Dockerfile'
+ - '*/*/variants.yaml'
+ - '*/*/rootfs/**'
+ - '*/*/.scripts/**'
+ - '*/*/otel/**'
+ - '*/latest'
+ workflow_dispatch:
+ inputs:
+ target:
+ description: 'Image/version to build (e.g. hyperf/8.3 or hyperf/latest).'
+ required: true
+
+permissions:
+ contents: read
+
+jobs:
+ detect:
+ runs-on: ubuntu-latest
+ outputs:
+ builds: ${{ steps.list.outputs.builds }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Get changed files
+ id: changed
+ if: github.event_name == 'push'
+ uses: tj-actions/changed-files@v44
+
+ - name: Build matrix
+ id: list
+ shell: bash
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ INPUT_TARGET: ${{ inputs.target }}
+ CHANGED_FILES: ${{ steps.changed.outputs.all_changed_files }}
+ run: |
+ set -euo pipefail
+
+ declare -a versions=()
+
+ collect_version() {
+ local v="$1"
+ [ -z "$v" ] && return
+ case "$v" in
+ [A-Za-z0-9_./-]*) ;;
+ *) echo "::warning::ignoring suspicious path '$v'"; return ;;
+ esac
+ local depth
+ depth=$(awk -F/ '{print NF}' <<< "$v")
+ if [ "$depth" != "2" ]; then return; fi
+ local resolved="$v"
+ if [ -L "$v" ]; then
+ local link_target
+ link_target=$(readlink "$v")
+ resolved="${v%/*}/${link_target}"
+ fi
+ if [ ! -f "${resolved}/Dockerfile" ]; then return; fi
+ for existing in "${versions[@]+"${versions[@]}"}"; do
+ if [ "$existing" = "$v" ]; then return; fi
+ done
+ versions+=("$v")
+ }
+
+ if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
+ collect_version "$INPUT_TARGET"
+ else
+ declare -a changed_dirs=()
+ for f in $CHANGED_FILES; do
+ dir=$(awk -F/ 'NF>=2 {print $1"/"$2}' <<< "$f")
+ [ -z "$dir" ] && continue
+ for existing in "${changed_dirs[@]+"${changed_dirs[@]}"}"; do
+ if [ "$existing" = "$dir" ]; then dir=""; break; fi
+ done
+ [ -n "$dir" ] && changed_dirs+=("$dir")
+ done
+
+ for v in "${changed_dirs[@]+"${changed_dirs[@]}"}"; do
+ collect_version "$v"
+ done
+
+ for symlink in */latest; do
+ [ -L "$symlink" ] || continue
+ target=$(readlink "$symlink")
+ parent="${symlink%/*}"
+ resolved="${parent}/${target}"
+ for changed in "${changed_dirs[@]+"${changed_dirs[@]}"}"; do
+ if [ "$changed" = "$resolved" ]; then
+ collect_version "$symlink"
+ break
+ fi
+ done
+ done
+ fi
+
+ declare -a builds=()
+ for v in "${versions[@]+"${versions[@]}"}"; do
+ image="${v%%/*}"
+ version="${v##*/}"
+ ctx="$v"
+ if [ -L "$ctx" ]; then
+ link_target=$(readlink "$ctx")
+ ctx="${ctx%/*}/${link_target}"
+ fi
+
+ manifest="${ctx}/variants.yaml"
+ if [ -f "$manifest" ]; then
+ if ! yq eval 'all_c(.target != null and .target != "" and has("suffix"))' "$manifest" | grep -qx true; then
+ echo "::error file=${manifest}::each entry must declare 'target' (non-empty) and 'suffix' (key required, value may be empty)"
+ exit 1
+ fi
+ entries=$(yq eval -o=json "$manifest")
+ else
+ entries='[{"target":"'"$image"'","suffix":""}]'
+ fi
+
+ while IFS= read -r line; do
+ [ -z "$line" ] && continue
+ builds+=("$line")
+ done < <(jq -c --arg img "$image" --arg ver "$version" --arg ctx "$ctx" '
+ .[] | {
+ image: $img,
+ tag: ($ver + (if (.suffix // "") == "" then "" else "-" + .suffix end)),
+ context: $ctx,
+ target: .target,
+ build_args: ((.args // {}) | to_entries | map(.key + "=" + (.value | tostring)) | join("\n"))
+ }
+ ' <<< "$entries")
+ done
+
+ if [ ${#builds[@]} -eq 0 ]; then
+ echo "builds=[]" >> "$GITHUB_OUTPUT"
+ else
+ joined=$(IFS=,; echo "${builds[*]}")
+ echo "builds=[${joined}]" >> "$GITHUB_OUTPUT"
+ fi
+
+ build:
+ needs: detect
+ if: needs.detect.outputs.builds != '[]'
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ build: ${{ fromJson(needs.detect.outputs.builds) }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: ./${{ matrix.build.context }}
+ target: ${{ matrix.build.target }}
+ build-args: ${{ matrix.build.build_args }}
+ platforms: linux/amd64
+ push: true
+ tags: devitools/${{ matrix.build.image }}:${{ matrix.build.tag }}
+ cache-from: type=gha,scope=${{ matrix.build.image }}-${{ matrix.build.tag }}
+ cache-to: type=gha,mode=max,scope=${{ matrix.build.image }}-${{ matrix.build.tag }}
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
new file mode 100644
index 0000000..da6b786
--- /dev/null
+++ b/.github/workflows/validate.yml
@@ -0,0 +1,102 @@
+name: Validate variants.yaml
+
+on:
+ pull_request:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Get changed variants.yaml files
+ id: changed
+ uses: tj-actions/changed-files@v44
+ with:
+ files: '*/*/variants.yaml'
+
+ - name: Validate each manifest
+ if: steps.changed.outputs.any_changed == 'true'
+ shell: bash
+ env:
+ CHANGED_MANIFESTS: ${{ steps.changed.outputs.all_changed_files }}
+ run: |
+ set -uo pipefail
+ errors=0
+
+ for manifest in $CHANGED_MANIFESTS; do
+ case "$manifest" in
+ [A-Za-z0-9_./-]*) ;;
+ *) echo "::warning::skipping suspicious path '$manifest'"; continue ;;
+ esac
+ echo "::group::${manifest}"
+ dir="$(dirname "$manifest")"
+ dockerfile="${dir}/Dockerfile"
+
+ if ! yq eval '.' "$manifest" > /dev/null 2>&1; then
+ echo "::error file=${manifest}::invalid YAML syntax"
+ errors=$((errors+1))
+ echo "::endgroup::"
+ continue
+ fi
+
+ kind=$(yq eval 'type' "$manifest")
+ if [ "$kind" != "!!seq" ]; then
+ echo "::error file=${manifest}::root must be a YAML list (got ${kind})"
+ errors=$((errors+1))
+ echo "::endgroup::"
+ continue
+ fi
+
+ entries=$(yq eval -o=json "$manifest")
+
+ bad=$(jq -c '[.[] | select((.target | type) != "string" or .target == "" or (has("suffix") | not))]' <<< "$entries")
+ if [ "$(jq 'length' <<< "$bad")" != "0" ]; then
+ echo "::error file=${manifest}::entries missing required 'target' (non-empty string) or 'suffix' key:"
+ jq -r '.[] | " - " + (. | tostring)' <<< "$bad"
+ errors=$((errors+1))
+ fi
+
+ bad_args=$(jq -c '[.[] | select(has("args") and (.args | type) != "object")]' <<< "$entries")
+ if [ "$(jq 'length' <<< "$bad_args")" != "0" ]; then
+ echo "::error file=${manifest}::'args' must be a mapping when present:"
+ jq -r '.[] | " - " + (. | tostring)' <<< "$bad_args"
+ errors=$((errors+1))
+ fi
+
+ if [ ! -f "$dockerfile" ]; then
+ echo "::error file=${manifest}::sibling Dockerfile not found at ${dockerfile}"
+ errors=$((errors+1))
+ else
+ for target in $(jq -r '.[].target' <<< "$entries" | sort -u); do
+ if ! grep -qE "^FROM[[:space:]]+.*[[:space:]]+AS[[:space:]]+${target}([[:space:]]|$)" "$dockerfile"; then
+ echo "::error file=${manifest}::target '${target}' is not declared as a stage in ${dockerfile} (expected: FROM AS ${target})"
+ errors=$((errors+1))
+ fi
+ done
+ fi
+
+ duplicates=$(jq -r '
+ [.[] | (if (.suffix // "") == "" then "" else .suffix end)] |
+ group_by(.) | map(select(length > 1)) | map(.[0]) | .[]
+ ' <<< "$entries" | sort -u)
+ if [ -n "$duplicates" ]; then
+ echo "::error file=${manifest}::duplicate suffix(es) detected (would produce colliding tags):"
+ echo "$duplicates" | sed 's/^/ - /'
+ errors=$((errors+1))
+ fi
+
+ echo "::endgroup::"
+ done
+
+ if [ $errors -gt 0 ]; then
+ echo "Found ${errors} error(s) across the changed manifests."
+ exit 1
+ fi
+ echo "All changed variants.yaml are valid."
diff --git a/hyperf/.scripts/setup-dev.sh b/hyperf/8.3/.scripts/setup-dev.sh
similarity index 100%
rename from hyperf/.scripts/setup-dev.sh
rename to hyperf/8.3/.scripts/setup-dev.sh
diff --git a/hyperf/.scripts/setup.sh b/hyperf/8.3/.scripts/setup.sh
similarity index 100%
rename from hyperf/.scripts/setup.sh
rename to hyperf/8.3/.scripts/setup.sh
diff --git a/hyperf/Dockerfile b/hyperf/8.3/Dockerfile
similarity index 59%
rename from hyperf/Dockerfile
rename to hyperf/8.3/Dockerfile
index f9011c2..9626fdb 100644
--- a/hyperf/Dockerfile
+++ b/hyperf/8.3/Dockerfile
@@ -1,4 +1,4 @@
-FROM alpine:3.20
+FROM alpine:3.20 AS hyperf
ARG CONTEXT
ARG TIMEZONE
@@ -57,18 +57,39 @@ COPY --from=composer/composer:2.8.5-bin /composer /usr/local/bin/composer
COPY .scripts /devitools/.scripts
-# update
RUN set -ex \
- # ---------- apply settings -------\
&& bash /devitools/.scripts/setup.sh "$TIMEZONE" \
&& bash /devitools/.scripts/setup-dev.sh "$APP_TARGET" \
- # ---------- clear works ----------\
&& rm -rf /var/cache/apk/* /tmp/* /usr/share/man
WORKDIR /opt/www
-
EXPOSE 9501
+ENTRYPOINT ["php", "/opt/www/bin/hyperf.php"]
+CMD ["start"]
+
+# --- Variant: with OTEL Collector + pgbouncer + supervisor ---
+FROM hyperf AS hyperf-otel
+
+ARG COLLECTOR=debug
+ARG OTEL_COLLECTOR_VERSION=0.121.0
+
+RUN apk add --no-cache --upgrade expat \
+ && apk add --no-cache supervisor wget pgbouncer \
+ && OTEL_BASE_URL="https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v${OTEL_COLLECTOR_VERSION}" \
+ && OTEL_ASSET="otelcol-contrib_${OTEL_COLLECTOR_VERSION}_linux_amd64.tar.gz" \
+ && cd /tmp \
+ && wget -q "${OTEL_BASE_URL}/${OTEL_ASSET}" -O "${OTEL_ASSET}" \
+ && wget -q "${OTEL_BASE_URL}/opentelemetry-collector-releases_otelcol-contrib_checksums.txt" -O checksums.txt \
+ && grep " ${OTEL_ASSET}$" checksums.txt | sha256sum -c - \
+ && tar -xzf "${OTEL_ASSET}" -C /usr/local/bin otelcol-contrib \
+ && rm "${OTEL_ASSET}" checksums.txt \
+ && chmod +x /usr/local/bin/otelcol-contrib \
+ && mkdir -p /etc/pgbouncer /var/log/pgbouncer /var/run/pgbouncer /etc/supervisor.d /var/run/supervisor
-ENTRYPOINT [ "php", "/opt/www/bin/hyperf.php" ]
+COPY otel/collectors/${COLLECTOR}.yaml /etc/otel-collector-config.yaml
+COPY otel/supervisord.conf /etc/supervisord.conf
+COPY otel/entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
-CMD [ "start" ]
+ENTRYPOINT ["/entrypoint.sh"]
+CMD []
diff --git a/hyperf/README-pt-BR.md b/hyperf/8.3/README-pt-BR.md
similarity index 100%
rename from hyperf/README-pt-BR.md
rename to hyperf/8.3/README-pt-BR.md
diff --git a/hyperf/README.md b/hyperf/8.3/README.md
similarity index 100%
rename from hyperf/README.md
rename to hyperf/8.3/README.md
diff --git a/hyperf/8.3/otel/collectors/debug.yaml b/hyperf/8.3/otel/collectors/debug.yaml
new file mode 100644
index 0000000..4dc8718
--- /dev/null
+++ b/hyperf/8.3/otel/collectors/debug.yaml
@@ -0,0 +1,22 @@
+receivers:
+ zipkin:
+ endpoint: "0.0.0.0:9411"
+
+processors:
+ batch:
+ send_batch_size: 200
+ timeout: 5s
+ memory_limiter:
+ check_interval: 1s
+ limit_mib: 256
+
+exporters:
+ debug:
+ verbosity: normal
+
+service:
+ pipelines:
+ traces:
+ receivers: [zipkin]
+ processors: [memory_limiter, batch]
+ exporters: [debug]
diff --git a/hyperf/8.3/otel/collectors/google.yaml b/hyperf/8.3/otel/collectors/google.yaml
new file mode 100644
index 0000000..663fae1
--- /dev/null
+++ b/hyperf/8.3/otel/collectors/google.yaml
@@ -0,0 +1,22 @@
+receivers:
+ zipkin:
+ endpoint: "0.0.0.0:9411"
+
+processors:
+ batch:
+ send_batch_size: 200
+ timeout: 5s
+ memory_limiter:
+ check_interval: 1s
+ limit_mib: 256
+
+exporters:
+ googlecloud:
+ project: ${GOOGLE_CLOUD_PROJECT}
+
+service:
+ pipelines:
+ traces:
+ receivers: [zipkin]
+ processors: [memory_limiter, batch]
+ exporters: [googlecloud]
diff --git a/hyperf/8.3/otel/entrypoint.sh b/hyperf/8.3/otel/entrypoint.sh
new file mode 100755
index 0000000..57d6b3c
--- /dev/null
+++ b/hyperf/8.3/otel/entrypoint.sh
@@ -0,0 +1,112 @@
+#!/bin/sh
+set -e
+
+if [ "$PGBOUNCER_ENABLED" != "true" ]; then
+ exec supervisord -c /etc/supervisord.conf
+fi
+
+PGBOUNCER_DATABASES="${PGBOUNCER_DATABASES:-default}"
+
+DEFAULT_POOL_SIZE="${PGBOUNCER_DEFAULT_POOL_SIZE:-5}"
+MIN_POOL_SIZE="${PGBOUNCER_MIN_POOL_SIZE:-1}"
+RESERVE_POOL_SIZE="${PGBOUNCER_RESERVE_POOL_SIZE:-2}"
+MAX_CLIENT_CONN="${PGBOUNCER_MAX_CLIENT_CONN:-1000}"
+MAX_PREPARED_STATEMENTS="${PGBOUNCER_MAX_PREPARED_STATEMENTS:-100}"
+SERVER_IDLE_TIMEOUT="${PGBOUNCER_SERVER_IDLE_TIMEOUT:-300}"
+SERVER_LIFETIME="${PGBOUNCER_SERVER_LIFETIME:-3600}"
+
+validate_alias() {
+ case "$1" in
+ *[!A-Za-z0-9_]*|"")
+ echo "entrypoint: invalid PGBOUNCER_DATABASES alias '$1' (allowed: [A-Za-z0-9_])" >&2
+ exit 1
+ ;;
+ esac
+}
+
+resolve_prefix() {
+ if [ "$1" = "default" ]; then
+ echo "POSTGRES_DB"
+ else
+ echo "POSTGRES_DB_$(echo "$1" | tr '[:lower:]' '[:upper:]')"
+ fi
+}
+
+echo "[databases]" > /etc/pgbouncer/pgbouncer.ini
+
+OLD_IFS="$IFS"
+IFS=','
+for alias in $PGBOUNCER_DATABASES; do
+ IFS="$OLD_IFS"
+ alias=$(echo "$alias" | tr -d ' ')
+ if [ -n "$alias" ]; then
+ validate_alias "$alias"
+ prefix=$(resolve_prefix "$alias")
+ host=$(eval "printf '%s' \"\${${prefix}_HOST:-}\"")
+ port=$(eval "printf '%s' \"\${${prefix}_PORT:-5432}\"")
+ name=$(eval "printf '%s' \"\${${prefix}_NAME:-}\"")
+ user=$(eval "printf '%s' \"\${${prefix}_USERNAME:-}\"")
+ pass=$(eval "printf '%s' \"\${${prefix}_PASSWORD:-}\"")
+
+ [ -z "$host" ] && { echo "entrypoint: ${prefix}_HOST is required for alias '${alias}'" >&2; exit 1; }
+ [ -z "$name" ] && { echo "entrypoint: ${prefix}_NAME is required for alias '${alias}'" >&2; exit 1; }
+ [ -z "$user" ] && { echo "entrypoint: ${prefix}_USERNAME is required for alias '${alias}'" >&2; exit 1; }
+ [ -z "$pass" ] && { echo "entrypoint: ${prefix}_PASSWORD is required for alias '${alias}'" >&2; exit 1; }
+
+ echo "pgb_${alias} = host=${host} port=${port} dbname=${name} user=${user} password=${pass}" >> /etc/pgbouncer/pgbouncer.ini
+ fi
+ IFS=','
+done
+IFS="$OLD_IFS"
+
+cat >> /etc/pgbouncer/pgbouncer.ini < /etc/supervisor.d/pgbouncer.ini <