diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..264c9db --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.github +helm + +.env +.env.* + +node_modules + +# Local/IDE noise +.idea +.vscode +.DS_Store + +# Composer install output (built inside Docker) +vendor +web/wp + diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..18f31e6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.php] +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0ea6fc0 --- /dev/null +++ b/.env.example @@ -0,0 +1,67 @@ +# ----------------------------------------------------------------------------- +# Bedrock / WordPress configuration (safe example) +# ----------------------------------------------------------------------------- + +WP_ENV=development +WP_HOME=http://localhost:8080 +# Keep WP_SITEURL explicit for compatibility with tooling that does not +# interpolate variables inside env files. +WP_SITEURL=http://localhost:8080/wp + +# Optional TLS dev endpoint: +# WP_HOME=https://wp.localhost:8443 +# WP_SITEURL=https://wp.localhost:8443/wp + +# ----------------------------------------------------------------------------- +# Database (local dev defaults) +# ----------------------------------------------------------------------------- + +DB_NAME=wordpress +DB_USER=wordpress +DB_PASSWORD=wordpress +DB_HOST=db +DB_PREFIX=wp_ +DB_ROOT_PASSWORD=root + +# ----------------------------------------------------------------------------- +# Authentication Keys and Salts +# Generate new values for real environments: +# https://roots.io/salts.html +# ----------------------------------------------------------------------------- + +AUTH_KEY='generateme' +SECURE_AUTH_KEY='generateme' +LOGGED_IN_KEY='generateme' +NONCE_KEY='generateme' +AUTH_SALT='generateme' +SECURE_AUTH_SALT='generateme' +LOGGED_IN_SALT='generateme' +NONCE_SALT='generateme' + +# ----------------------------------------------------------------------------- +# WordPress hardening / behavior +# ----------------------------------------------------------------------------- + +DISABLE_WP_CRON=false +WP_POST_REVISIONS=25 + +# ----------------------------------------------------------------------------- +# Redis object cache (optional; requires a Redis cache plugin) +# ----------------------------------------------------------------------------- + +WP_CACHE=false +WP_REDIS_HOST=redis +WP_REDIS_PORT=6379 + +# Optional: set the default theme (committed placeholder theme is `starter-theme`) +WP_DEFAULT_THEME=starter-theme + +# ----------------------------------------------------------------------------- +# Docker dev ergonomics +# ----------------------------------------------------------------------------- +# +# Used to build the php-dev image with a UID/GID matching your host user so +# bind-mounts and generated files are writable without manual chmod/chown. +APP_UID=1000 +APP_GID=1000 + diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1d0cd96 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +/.editorconfig export-ignore +/.gitattributes export-ignore +/.github export-ignore + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7063f03 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a0bc203 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,120 @@ +name: CI + +on: + pull_request: + push: + branches: ["main"] + +permissions: + contents: read + +jobs: + php: + name: Composer validate / lint / audit + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + tools: composer:v2 + coverage: none + extensions: intl, mbstring, curl, zip + ini-values: memory_limit=512M + + - name: Composer validate + run: composer validate --strict + + - name: Composer install (dev) + run: composer install --no-interaction --no-progress + + - name: Lint (Pint) + run: composer lint + + - name: Composer audit + run: composer audit + + local-smoke: + name: Local stack smoke (Docker Compose) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Prepare local .env for CI + run: | + cp .env.example .env + echo "APP_UID=$(id -u)" >> .env + echo "APP_GID=$(id -g)" >> .env + echo "WP_ADMIN_PASSWORD=ci-smoke-password" >> .env + + - name: Docker versions + run: | + docker version + docker compose version + + - name: Doctor preflight + run: make doctor + + - name: Bootstrap stack + run: make bootstrap + + - name: Smoke checks + run: make smoke + + - name: Compose status/logs on failure + if: failure() + run: | + docker compose ps + docker compose logs --no-color --tail=200 + + - name: Teardown + if: always() + run: docker compose down -v --remove-orphans + + helm: + name: Helm lint / template + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Helm + uses: azure/setup-helm@v4 + + - name: Helm lint + run: > + helm lint helm/wp-boilerplate + --set image.php.tag=ci + --set image.web.tag=ci + + - name: Helm template (render) + run: > + helm template wp helm/wp-boilerplate + --namespace wp + --set image.php.tag=ci + --set image.web.tag=ci + > /tmp/wp-boilerplate.rendered.yaml + + - name: Install kubeconform + run: | + set -euo pipefail + KUBECONFORM_VERSION="v0.7.0" + curl -fsSLo kubeconform.tar.gz "https://github.com/yannh/kubeconform/releases/download/${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz" + tar -xzf kubeconform.tar.gz kubeconform + sudo mv kubeconform /usr/local/bin/kubeconform + + - name: Kubeconform validate rendered manifests + run: | + set -euo pipefail + kubeconform \ + -strict \ + -summary \ + -ignore-missing-schemas \ + -kubernetes-version 1.29.0 \ + /tmp/wp-boilerplate.rendered.yaml + diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..67b2b5d --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,38 @@ +name: CodeQL + +on: + push: + branches: ["main"] + pull_request: + schedule: + - cron: "23 3 * * 1" + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: ["php"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + diff --git a/.github/workflows/container-build.yml b/.github/workflows/container-build.yml new file mode 100644 index 0000000..9d69b30 --- /dev/null +++ b/.github/workflows/container-build.yml @@ -0,0 +1,143 @@ +name: Container Build + +on: + push: + branches: ["**"] + pull_request: + workflow_dispatch: + +concurrency: + group: container-build-${{ github.ref }} + cancel-in-progress: true + +env: + REGISTRY: ghcr.io + IMAGE_PHP: ${{ github.repository }}-php + IMAGE_NGINX: ${{ github.repository }}-nginx + +permissions: + contents: read + packages: write + id-token: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata (php) + id: meta_php + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_PHP }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha,format=short + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build (php) + id: build_php + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + target: php-runtime + push: ${{ github.event_name != 'pull_request' }} + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta_php.outputs.tags }} + labels: ${{ steps.meta_php.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + sbom: true + provenance: true + + - name: Docker metadata (nginx) + id: meta_nginx + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NGINX }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha,format=short + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build (nginx) + id: build_nginx + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + target: nginx-runtime + push: ${{ github.event_name != 'pull_request' }} + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta_nginx.outputs.tags }} + labels: ${{ steps.meta_nginx.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + sbom: true + provenance: true + + - name: Trivy scan (php image) + if: github.event_name != 'pull_request' + uses: aquasecurity/trivy-action@0.33.1 + with: + image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_PHP }}@${{ steps.build_php.outputs.digest }} + format: table + exit-code: "1" + ignore-unfixed: true + vuln-type: os,library + severity: CRITICAL + + - name: Trivy scan (nginx image) + if: github.event_name != 'pull_request' + uses: aquasecurity/trivy-action@0.33.1 + with: + image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NGINX }}@${{ steps.build_nginx.outputs.digest }} + format: table + exit-code: "1" + ignore-unfixed: true + vuln-type: os,library + severity: CRITICAL + + - name: Install Cosign + if: github.event_name != 'pull_request' + uses: sigstore/cosign-installer@v4.0.0 + with: + cosign-release: v3.0.4 + + - name: Cosign sign images (keyless) + if: github.event_name != 'pull_request' + env: + COSIGN_YES: "true" + run: | + cosign sign "${{ env.REGISTRY }}/${{ env.IMAGE_PHP }}@${{ steps.build_php.outputs.digest }}" + cosign sign "${{ env.REGISTRY }}/${{ env.IMAGE_NGINX }}@${{ steps.build_nginx.outputs.digest }}" + + - name: Write digests to summary + if: github.event_name != 'pull_request' + run: | + { + echo "## Image digests" + echo "" + echo "- PHP: \`${{ env.REGISTRY }}/${{ env.IMAGE_PHP }}@${{ steps.build_php.outputs.digest }}\`" + echo "- Nginx: \`${{ env.REGISTRY }}/${{ env.IMAGE_NGINX }}@${{ steps.build_nginx.outputs.digest }}\`" + } >> "$GITHUB_STEP_SUMMARY" + diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..3768dcb --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,17 @@ +name: Dependency Review + +on: + pull_request: + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Dependency Review + uses: actions/dependency-review-action@v4 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47b1cd2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Application +web/app/plugins/* +!web/app/plugins/.gitkeep +web/app/mu-plugins/*/ +web/app/themes/* +!web/app/themes/starter-theme/ +!web/app/themes/starter-theme/** +web/app/upgrade +web/app/uploads/* +!web/app/uploads/.gitkeep +web/app/cache/* +!web/app/cache/.gitkeep + +# WordPress (managed by Composer) +web/wp +web/.htaccess + +# Logs +*.log + +# Dotenv +.env +.env.* +!.env.example + +# Composer +/vendor +auth.json + +# WP-CLI +wp-cli.local.yml + +# Node (if you add a frontend toolchain later) +node_modules + +# OS / IDE +.DS_Store +.idea +.vscode + +# Local TLS certs (mkcert) +.certs/ + diff --git a/.sonarcloud.properties b/.sonarcloud.properties new file mode 100644 index 0000000..1cc31ad --- /dev/null +++ b/.sonarcloud.properties @@ -0,0 +1,18 @@ +# SonarCloud configuration for this boilerplate repository. +# +# Project identity is typically provided by CI (recommended), for example: +# -Dsonar.organization= +# -Dsonar.projectKey=_ +# +# You can uncomment and set the values below if you prefer storing them here. +# sonar.organization= +# sonar.projectKey= + +sonar.projectName=Modern Cloud Native WordPress Boilerplate +sonar.sourceEncoding=UTF-8 + +# Scan the repository, then explicitly exclude generated/third-party/runtime paths. +sonar.sources=. +sonar.exclusions=vendor/**,web/wp/**,web/app/uploads/**,web/app/plugins/**,web/app/cache/**,.certs/**,**/.gitkeep +sonar.cpd.exclusions=vendor/**,web/wp/** + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..abe6c76 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,29 @@ +# Contributing + +Thanks for contributing! + +## Guidelines + +- **No secrets**: do not commit `.env`, API keys, private certs, or credentials. +- **Keep it generic**: this is a public boilerplate; avoid organization-specific references. +- **Production-minded defaults**: prefer least privilege, read-only root filesystems, and supply-chain hygiene. + +## Workflow (trunk-based) + +- Create a short-lived branch from `main` +- Keep changes small and focused +- Open a PR early +- Merge back to `main` frequently + +The CI builds branch-scoped container images and (on `main`) produces stable images intended to be deployed by digest. + +## Local Development + +See the repository README for Docker Compose commands. + +Before opening a PR, run: + +```bash +make qa +``` + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c75306c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,167 @@ +# syntax=docker/dockerfile:1.7 + +ARG PHP_VERSION=8.3 +ARG NGINX_VERSION=1.27 + +FROM composer:2 AS composer + +# ----------------------------------------------------------------------------- +# PHP base (extensions + runtime libs) +# ----------------------------------------------------------------------------- +FROM php:${PHP_VERSION}-fpm-alpine AS php-base + +RUN set -eux; \ + apk add --no-cache \ + icu-libs \ + libzip \ + libpng \ + libjpeg-turbo \ + freetype \ + ; \ + apk add --no-cache --virtual .build-deps \ + $PHPIZE_DEPS \ + icu-dev \ + libzip-dev \ + libpng-dev \ + libjpeg-turbo-dev \ + freetype-dev \ + ; \ + docker-php-ext-configure gd --with-freetype --with-jpeg; \ + docker-php-ext-install -j"$(nproc)" \ + intl \ + mysqli \ + opcache \ + pdo_mysql \ + zip \ + gd \ + ; \ + pecl install redis apcu; \ + docker-php-ext-enable redis apcu; \ + apk del .build-deps + +# ----------------------------------------------------------------------------- +# Build stage: composer install (no-dev) to produce vendor/ + web/wp +# ----------------------------------------------------------------------------- +FROM php-base AS build + +WORKDIR /var/www/html + +COPY --from=composer /usr/bin/composer /usr/local/bin/composer + +ENV COMPOSER_ALLOW_SUPERUSER=1 \ + COMPOSER_HOME=/tmp/composer + +COPY composer.json composer.lock ./ + +RUN --mount=type=cache,target=/tmp/composer/cache \ + composer install \ + --no-dev \ + --no-interaction \ + --no-progress \ + --prefer-dist + +COPY config/ config/ +COPY web/ web/ + +# ----------------------------------------------------------------------------- +# PHP runtime (non-root, production-minded) +# ----------------------------------------------------------------------------- +FROM php-base AS php-runtime + +WORKDIR /var/www/html + +RUN set -eux; \ + addgroup -g 10001 -S app; \ + adduser -u 10001 -S -G app app; \ + # Ensure the PHP user can create an FPM socket readable by the nginx-unprivileged + # container (uid/gid 101). + group101="$(awk -F: '$3==101{print $1; exit}' /etc/group)"; \ + if [ -z "$group101" ]; then addgroup -g 101 -S web; group101="web"; fi; \ + addgroup app "$group101" || true + +# Replace the default pool with our socket-based pool. +RUN rm -f /usr/local/etc/php-fpm.d/www.conf +COPY docker/php/fpm-pool.conf /usr/local/etc/php-fpm.d/zz-app.conf + +COPY docker/php/php.ini /usr/local/etc/php/php.ini +COPY docker/php/conf.d/50-apcu.ini /usr/local/etc/php/conf.d/50-apcu.ini +COPY docker/php/conf.d/99-opcache-prod.ini /usr/local/etc/php/conf.d/99-opcache.ini + +COPY --from=build --chown=app:app /var/www/html /var/www/html + +# Writable paths are expected to be mounted in production: +# - /tmp (tmpfs) +# - /var/run/php (socket) +# - web/app/uploads (uploads) +# - web/app/cache (caches) +RUN mkdir -p /tmp /var/run/php /var/www/html/web/app/uploads /var/www/html/web/app/cache && \ + chown -R app:app /var/run/php /var/www/html/web/app/uploads /var/www/html/web/app/cache + +USER app + +EXPOSE 9000 + +CMD ["php-fpm", "-F"] + +# ----------------------------------------------------------------------------- +# PHP dev (no app baked; intended for bind-mount local source) +# ----------------------------------------------------------------------------- +FROM php-base AS php-dev + +ARG APP_UID=1000 +ARG APP_GID=1000 + +WORKDIR /var/www/html + +RUN set -eux; \ + addgroup -g "${APP_GID}" -S app; \ + adduser -u "${APP_UID}" -S -G app app; \ + group101="$(awk -F: '$3==101{print $1; exit}' /etc/group)"; \ + if [ -z "$group101" ]; then addgroup -g 101 -S web; group101="web"; fi; \ + addgroup app "$group101" || true + +RUN rm -f /usr/local/etc/php-fpm.d/www.conf +COPY docker/php/fpm-pool.conf /usr/local/etc/php-fpm.d/zz-app.conf + +COPY docker/php/php.ini /usr/local/etc/php/php.ini +COPY docker/php/conf.d/50-apcu.ini /usr/local/etc/php/conf.d/50-apcu.ini +COPY docker/php/conf.d/99-opcache-dev.ini /usr/local/etc/php/conf.d/99-opcache.ini +COPY --from=composer /usr/bin/composer /usr/local/bin/composer + +RUN mkdir -p /tmp /var/run/php /var/www/html/web/app/uploads /var/www/html/web/app/cache && \ + chown -R app:app /var/run/php /var/www/html/web/app/uploads /var/www/html/web/app/cache + +USER app + +EXPOSE 9000 + +CMD ["php-fpm", "-F"] + +# ----------------------------------------------------------------------------- +# Nginx base (non-root) +# ----------------------------------------------------------------------------- +FROM nginxinc/nginx-unprivileged:${NGINX_VERSION}-alpine AS nginx-base + +WORKDIR /var/www/html + +USER root +COPY docker/nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf +COPY docker/nginx/snippets/ /etc/nginx/snippets/ +USER 101 + +EXPOSE 8080 + +# ----------------------------------------------------------------------------- +# Nginx runtime (bakes Bedrock web/ for immutable deployments) +# ----------------------------------------------------------------------------- +FROM nginx-base AS nginx-runtime + +USER root +COPY --from=build --chown=101:101 /var/www/html/web /var/www/html/web +USER 101 + +# ----------------------------------------------------------------------------- +# Nginx dev (no app baked; intended for bind-mount local source) +# ----------------------------------------------------------------------------- +FROM nginx-base AS nginx-dev + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..97f7922 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The Contributors + +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/Makefile b/Makefile new file mode 100644 index 0000000..b5edd1e --- /dev/null +++ b/Makefile @@ -0,0 +1,134 @@ +SHELL := /usr/bin/env bash + +COMPOSE ?= docker compose +RUN_USER ?= $(shell id -u 2>/dev/null || echo 1000):$(shell id -g 2>/dev/null || echo 1000) + +.PHONY: help install composer-install up down restart ps logs shell up-mail up-dbadmin up-observability wp composer +.PHONY: bootstrap env wait wp-install up-tls bootstrap-tls certs-mkcert up-tls-trusted bootstrap-tls-trusted doctor smoke smoke-full qa + +help: ## Show available targets + @awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z0-9_.-]+:.*##/ {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +env: ## Create .env from .env.example if missing + @if [ -f .env ]; then echo ".env exists"; else cp .env.example .env && echo "Created .env from .env.example"; fi + +doctor: ## Preflight checks (docker, compose, ports, env, optional mkcert) + @bash scripts/doctor.sh + +smoke: ## Smoke-test running local stack (HTTP + wp core is-installed) + @bash scripts/smoke.sh + +smoke-full: ## Doctor + bootstrap + smoke checks + $(MAKE) doctor + $(MAKE) bootstrap + $(MAKE) smoke + +qa: ## Full QA: composer checks + docker smoke-full when available + @bash scripts/qa.sh + +up: ## Start dev stack (build + up) + $(COMPOSE) up -d --build + +install: ## Install deps (Composer) then start stack + $(MAKE) composer-install + $(MAKE) up + +wait: ## Wait for php-fpm ping via Nginx + @echo "Waiting for http://localhost:8080/ping ..." + @for i in $$(seq 1 60); do \ + if command -v curl >/dev/null 2>&1; then \ + curl -fsS http://localhost:8080/ping >/dev/null 2>&1 && echo "Ready" && exit 0; \ + else \ + wget -qO- http://localhost:8080/ping >/dev/null 2>&1 && echo "Ready" && exit 0; \ + fi; \ + sleep 1; \ + done; \ + echo "Timed out waiting for web/php"; \ + exit 1 + +bootstrap: ## One-command local bootstrap (env + deps + up + wait + wp-install) + $(MAKE) env + $(MAKE) composer-install + $(MAKE) up + $(MAKE) wait + $(MAKE) wp-install + +up-tls: ## Start dev stack + local TLS proxy (https://wp.localhost:8443) + $(COMPOSE) --profile tls up -d --build + +bootstrap-tls: ## Bootstrap stack + local TLS proxy (requires WP_HOME/WP_SITEURL set to https://wp.localhost:8443) + $(MAKE) env + $(MAKE) composer-install + $(MAKE) up-tls + $(MAKE) wait + $(MAKE) wp-install + +certs-mkcert: ## Generate trusted local certs for wp.localhost using mkcert + @command -v mkcert >/dev/null 2>&1 || { echo "mkcert not found. Install it first: https://github.com/FiloSottile/mkcert"; exit 1; } + @mkdir -p .certs + @mkcert -install + @mkcert -cert-file .certs/wp.localhost.pem -key-file .certs/wp.localhost-key.pem wp.localhost + @echo "Generated .certs/wp.localhost.pem and .certs/wp.localhost-key.pem" + +up-tls-trusted: ## Start dev stack + trusted local TLS proxy (mkcert) + $(COMPOSE) --profile tls-trusted up -d --build + +bootstrap-tls-trusted: ## Bootstrap stack + trusted local TLS (runs mkcert first) + $(MAKE) env + $(MAKE) certs-mkcert + $(MAKE) composer-install + $(MAKE) up-tls-trusted + $(MAKE) wait + $(MAKE) wp-install + +down: ## Stop dev stack + $(COMPOSE) down --remove-orphans + +restart: ## Restart dev stack + $(COMPOSE) restart + +ps: ## Show container status + $(COMPOSE) ps + +logs: ## Tail logs (set SERVICE=... to filter) + @if [ -n "$(SERVICE)" ]; then $(COMPOSE) logs -f --tail=200 "$(SERVICE)"; else $(COMPOSE) logs -f --tail=200; fi + +shell: ## Shell into PHP container + $(COMPOSE) exec php sh + +up-mail: ## Start stack with MailHog profile + $(COMPOSE) --profile mail up -d --build + +up-dbadmin: ## Start stack with phpMyAdmin profile + $(COMPOSE) --profile dbadmin up -d --build + +up-observability: ## Start stack with exporters profile + $(COMPOSE) --profile observability up -d --build + +wp: ## Run WP-CLI (example: make wp ARGS="core version") + $(COMPOSE) --profile tools run --rm --user "$(RUN_USER)" wp $(ARGS) + +composer: ## Run Composer in a container (example: make composer ARGS="install") + $(COMPOSE) --profile tools run --rm --user "$(RUN_USER)" composer $(ARGS) + +composer-install: ## Install Composer deps into the working tree + $(COMPOSE) --profile tools run --rm --user "$(RUN_USER)" composer install --no-interaction --no-progress + +wp-install: ## Install WordPress if not already installed (local dev) + @set -euo pipefail; \ + if [ ! -f .env ]; then echo "Missing .env (run: make env)"; exit 1; fi; \ + set -a; . ./.env; set +a; \ + URL="$${WP_HOME:-http://localhost:8080}"; \ + TITLE="$${WP_SITE_TITLE:-Boilerplate}"; \ + ADMIN_USER="$${WP_ADMIN_USER:-admin}"; \ + ADMIN_PASSWORD="$${WP_ADMIN_PASSWORD:-admin}"; \ + ADMIN_EMAIL="$${WP_ADMIN_EMAIL:-admin@example.com}"; \ + $(COMPOSE) --profile tools run --rm --user "$(RUN_USER)" wp --path=web/wp core is-installed >/dev/null 2>&1 || \ + $(COMPOSE) --profile tools run --rm --user "$(RUN_USER)" wp --path=web/wp core install \ + --url="$$URL" \ + --title="$$TITLE" \ + --admin_user="$$ADMIN_USER" \ + --admin_password="$$ADMIN_PASSWORD" \ + --admin_email="$$ADMIN_EMAIL" \ + --skip-email + diff --git a/README.md b/README.md index a18f153..da54fbb 100644 --- a/README.md +++ b/README.md @@ -1 +1,193 @@ -# vd-wordpress-boilerplate +# Modern Cloud-Native WordPress Boilerplate (Bedrock) + +A production-minded WordPress boilerplate based on **[Roots Bedrock](https://roots.io/bedrock/)**, designed for: + +- Local development with **Docker Compose** (profiles for optional services) +- Hardened, reproducible **container builds** (multi-stage) +- **Kubernetes-first** deployment via **Helm** +- Supply-chain and security gates in **GitHub Actions** + +> This repo is intentionally generic and safe to publish: no internal domains, no secrets, and no private themes/plugins/submodules. + +--- + +## Quickstart (Local Development) + +### Prerequisites + +- Docker + Docker Compose v2 +- `make` + +### Run it + +```bash +cp .env.example .env +make doctor +make bootstrap +make smoke +# Full pre-push QA bundle (runs composer checks; runs smoke-full when Docker is available) +make qa +``` + +Then open: + +- http://localhost:8080 + +`make bootstrap` also runs a WP-CLI install if the site isn't installed yet. +Defaults (override in `.env`): + +- `WP_SITE_TITLE` (default: `Boilerplate`) +- `WP_ADMIN_USER` (default: `admin`) +- `WP_ADMIN_PASSWORD` (default: `admin`) +- `WP_ADMIN_EMAIL` (default: `admin@example.com`) + +You can re-run: + +```bash +make wp-install +``` + +To stop: + +```bash +make down +``` + +### Optional: local HTTPS (TLS) + +This repo includes optional Caddy reverse-proxy profiles for local HTTPS: + +**Option A: quick internal CA (browser warns)** + +```bash +# Update .env so WP_HOME/WP_SITEURL use https://wp.localhost:8443 +make bootstrap-tls +``` + +**Option B: trusted certs with mkcert (recommended for local UX)** + +```bash +# Requires mkcert installed on your machine +make bootstrap-tls-trusted +``` + +URL: + +- https://wp.localhost:8443 + +Notes: + +- For trusted TLS, `make certs-mkcert` generates local cert files under `.certs/` (gitignored). +- Set in `.env`: + - `WP_HOME=https://wp.localhost:8443` + - `WP_SITEURL=https://wp.localhost:8443/wp` +- Detailed guide: `docs/local-dev/tls-mkcert.md` + +### Optional profiles (dev conveniences) + +```bash +# Adds MailHog (SMTP capture UI at http://localhost:8025) +make up-mail + +# Adds phpMyAdmin (http://localhost:8081) +make up-dbadmin + +# Adds metrics exporters (Prometheus scrape targets) +make up-observability +``` + +--- + +## Production Images (Hardened) + +This boilerplate provides: + +- A PHP-FPM image (Bedrock + WordPress via Composer) +- A web image (Nginx, non-root) +- Secure-by-default runtime posture (drop caps, no privilege escalation, read-only root filesystem where possible) + +Images are intended to be built in CI and deployed **by digest** (build once, deploy everywhere). + +### Branch-based images + +The included GitHub Actions workflow builds and publishes images for each branch: + +- `ghcr.io//-php:` +- `ghcr.io//-nginx:` + +For production, prefer pinning by **digest** instead of tags. + +--- + +## Configuration (12-factor) + +- Local dev uses a `.env` file (see `.env.example`). +- Production should set env vars via **Kubernetes Secrets/ConfigMaps** (do not bake secrets into images). + +Common toggles: + +- `DISABLE_WP_CRON=true` (production) +- `DISALLOW_FILE_MODS=true` (production hardening) +- `WP_CACHE=true` + `WP_REDIS_HOST=...` (optional Redis object cache; plugin required) + +## Deploy to Kubernetes (Helm) + +Helm chart: `helm/wp-boilerplate` + +High-level steps: + +1. Push images to a registry (GHCR workflow included). +2. Create Kubernetes Secrets for database credentials and WordPress salts/keys. +3. Install the chart and pin images by digest. + +Example: + +```bash +helm upgrade --install wp helm/wp-boilerplate \ + --namespace wp --create-namespace \ + --set image.php.repository=ghcr.io/OWNER/REPO-php \ + --set image.web.repository=ghcr.io/OWNER/REPO-nginx \ + --set image.php.digest=sha256:... \ + --set image.web.digest=sha256:... +``` + +### Uploads storage strategy + +Preferred (cloud-native): use an S3-compatible uploads plugin (no shared PVC required). + +Alternative (simple clusters): enable the chart's `uploads.persistence` option to mount a PVC. + +Details: `docs/kubernetes/uploads-s3.md` + +--- + +## WP-Cron (Production) + +In production, you should disable the built-in pseudo-cron and run a real scheduler: + +- Set `DISABLE_WP_CRON=true` +- Use a Kubernetes `CronJob` (template included) or an external scheduler to trigger cron processing + +## Secrets on Kubernetes + +Recommended approaches: + +- External Secrets Operator (ESO) +- SealedSecrets + +See `docs/kubernetes/` for templates and required keys. + +## Supply chain / image verification + +CI signs published images (keyless Cosign). See `docs/supply-chain/cosign.md`. + +--- + +## License + +MIT. See [LICENSE](./LICENSE). + +## Security + +See [SECURITY.md](./SECURITY.md). + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b0fe116 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,33 @@ +# Security Policy + +## Supported Versions + +This repository is a boilerplate. Security updates are provided on a best-effort basis on the default branch. + +If you are using this boilerplate for a real deployment, you are responsible for: + +- Updating WordPress core (via Composer in Bedrock) +- Updating PHP / base container images +- Applying security updates to plugins/themes you add + +## Reporting a Vulnerability + +Please **do not** open a public GitHub issue for security reports. + +Preferred: use **GitHub Security Advisories** for private disclosure. + +1. Go to the repository page on GitHub +2. Click **Security** +3. Click **Report a vulnerability** + +If GitHub private reporting is not available, you may contact the maintainers by opening an issue asking for a private contact channel (do not include sensitive details). + +## Disclosure Policy + +We aim to: + +- Acknowledge receipt within **7 days** +- Provide a remediation plan or fix within **30 days** (when feasible) + +Timelines may vary depending on severity and available maintainer bandwidth. + diff --git a/compose.prod.yaml b/compose.prod.yaml new file mode 100644 index 0000000..b77dc22 --- /dev/null +++ b/compose.prod.yaml @@ -0,0 +1,62 @@ +services: + web: + image: ${IMAGE_WEB:-ghcr.io/OWNER/REPO-nginx:main} + ports: + - "8080:8080" + depends_on: + - php + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + tmpfs: + - /tmp:rw,noexec,nosuid,size=64m + - /var/cache/nginx:rw,nosuid,size=32m + volumes: + - php-socket:/var/run/php + - uploads:/var/www/html/web/app/uploads + - cache:/var/www/html/web/app/cache + + php: + image: ${IMAGE_PHP:-ghcr.io/OWNER/REPO-php:main} + env_file: + - .env + depends_on: + - db + - redis + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + tmpfs: + - /tmp:rw,noexec,nosuid,size=128m + volumes: + - php-socket:/var/run/php + - uploads:/var/www/html/web/app/uploads + - cache:/var/www/html/web/app/cache + + db: + image: mariadb:11.4 + environment: + MARIADB_DATABASE: ${DB_NAME:-wordpress} + MARIADB_USER: ${DB_USER:-wordpress} + MARIADB_PASSWORD: ${DB_PASSWORD:-wordpress} + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root} + volumes: + - db-data:/var/lib/mysql + + redis: + image: redis:7-alpine + command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"] + volumes: + - redis-data:/data + +volumes: + db-data: + redis-data: + php-socket: + uploads: + cache: + diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..169f58a --- /dev/null +++ b/compose.yaml @@ -0,0 +1,225 @@ +services: + # --------------------------------------------------------------------------- + # Web server (Nginx) - serves static files and forwards PHP to php-fpm socket. + # --------------------------------------------------------------------------- + web: + build: + context: . + dockerfile: Dockerfile + target: nginx-dev + ports: + - "8080:8080" + depends_on: + - php + volumes: + - ./:/var/www/html:ro + - php-socket:/var/run/php + - uploads:/var/www/html/web/app/uploads + - cache:/var/www/html/web/app/cache + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ping >/dev/null 2>&1 || exit 1"] + interval: 10s + timeout: 3s + retries: 10 + + # --------------------------------------------------------------------------- + # Optional: TLS reverse proxy for local dev. + # URL: https://wp.localhost:8443 + # --------------------------------------------------------------------------- + caddy: + image: caddy:2-alpine + profiles: ["tls"] + ports: + - "8443:8443" + volumes: + - ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + web: + condition: service_healthy + + # --------------------------------------------------------------------------- + # Optional: TLS reverse proxy with mkcert-provided trusted certs. + # URL: https://wp.localhost:8443 + # --------------------------------------------------------------------------- + caddy-mkcert: + image: caddy:2-alpine + profiles: ["tls-trusted"] + ports: + - "8443:8443" + volumes: + - ./docker/caddy/Caddyfile.mkcert:/etc/caddy/Caddyfile:ro + - ./.certs:/certs:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + web: + condition: service_healthy + + # --------------------------------------------------------------------------- + # PHP-FPM - Bedrock + WordPress via Composer. + # --------------------------------------------------------------------------- + php: + build: + context: . + dockerfile: Dockerfile + target: php-dev + args: + APP_UID: ${APP_UID:-1000} + APP_GID: ${APP_GID:-1000} + env_file: + - .env + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - ./:/var/www/html + - php-socket:/var/run/php + - uploads:/var/www/html/web/app/uploads + - cache:/var/www/html/web/app/cache + + # --------------------------------------------------------------------------- + # Database + # --------------------------------------------------------------------------- + db: + image: mariadb:11.4 + environment: + MARIADB_DATABASE: ${DB_NAME:-wordpress} + MARIADB_USER: ${DB_USER:-wordpress} + MARIADB_PASSWORD: ${DB_PASSWORD:-wordpress} + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root} + volumes: + - db-data:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -u root -p$$MARIADB_ROOT_PASSWORD --silent"] + interval: 10s + timeout: 5s + retries: 10 + + # --------------------------------------------------------------------------- + # Redis (optional object cache) + # --------------------------------------------------------------------------- + redis: + image: redis:7-alpine + command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"] + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 10 + + # --------------------------------------------------------------------------- + # Optional: MailHog (captures outgoing email for local dev) + # UI: http://localhost:8025 + # --------------------------------------------------------------------------- + mailhog: + image: mailhog/mailhog:v1.0.1 + profiles: ["mail"] + ports: + - "8025:8025" + - "1025:1025" + + # --------------------------------------------------------------------------- + # Optional: phpMyAdmin (dev-only DB admin) + # UI: http://localhost:8081 + # --------------------------------------------------------------------------- + phpmyadmin: + image: phpmyadmin:5 + profiles: ["dbadmin"] + environment: + PMA_HOST: db + PMA_USER: ${DB_USER:-wordpress} + PMA_PASSWORD: ${DB_PASSWORD:-wordpress} + ports: + - "8081:80" + depends_on: + db: + condition: service_healthy + + # --------------------------------------------------------------------------- + # Optional tools (run on-demand via `make wp ...` / `make composer ...`) + # --------------------------------------------------------------------------- + wp: + image: wordpress:cli-php8.3 + profiles: ["tools"] + env_file: + - .env + working_dir: /var/www/html + volumes: + - ./:/var/www/html + depends_on: + db: + condition: service_healthy + + composer: + image: composer:2 + profiles: ["tools"] + working_dir: /var/www/html + volumes: + - ./:/var/www/html + + # --------------------------------------------------------------------------- + # Optional observability: exporters (Prometheus scrape targets) + # --------------------------------------------------------------------------- + redis-exporter: + image: oliver006/redis_exporter:latest + profiles: ["observability"] + environment: + REDIS_ADDR: redis://redis:6379 + ports: + - "9121:9121" + depends_on: + redis: + condition: service_healthy + + mysqld-exporter: + image: prom/mysqld-exporter:latest + profiles: ["observability"] + environment: + DATA_SOURCE_NAME: root:${DB_ROOT_PASSWORD:-root}@(db:3306)/ + ports: + - "9104:9104" + depends_on: + db: + condition: service_healthy + + nginx-exporter: + image: nginx/nginx-prometheus-exporter:latest + profiles: ["observability"] + command: + - "--nginx.scrape-uri=http://web:8080/nginx_status" + ports: + - "9113:9113" + depends_on: + web: + condition: service_healthy + + php-fpm-exporter: + image: hipages/php-fpm_exporter:v2.2.0 + profiles: ["observability"] + user: "101:101" + environment: + PHP_FPM_SCRAPE_URI: unix:///var/run/php/php-fpm.sock;/status + PHP_FPM_FIX_PROCESS_COUNT: "true" + volumes: + - php-socket:/var/run/php:ro + ports: + - "9253:9253" + depends_on: + php: + condition: service_started + +volumes: + db-data: + redis-data: + php-socket: + uploads: + cache: + caddy-data: + caddy-config: + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..7e6dfaf --- /dev/null +++ b/composer.json @@ -0,0 +1,72 @@ +{ + "name": "cloud-native/wordpress-bedrock-boilerplate", + "type": "project", + "license": "MIT", + "description": "Modern cloud-native WordPress boilerplate built on Roots Bedrock", + "keywords": [ + "bedrock", + "composer", + "wordpress", + "docker", + "kubernetes", + "helm" + ], + "repositories": [ + { + "name": "wpackagist", + "type": "composer", + "url": "https://wpackagist.org", + "only": [ + "wpackagist-plugin/*", + "wpackagist-theme/*" + ] + } + ], + "require": { + "php": ">=8.2", + "composer/installers": "^2.2", + "oscarotero/env": "^2.1", + "roots/bedrock-autoloader": "^1.0", + "roots/bedrock-disallow-indexing": "^2.0", + "roots/wordpress": "^6.9.1", + "roots/wp-config": "^1.0.0", + "vlucas/phpdotenv": "^5.6" + }, + "require-dev": { + "laravel/pint": "^1.18" + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "allow-plugins": { + "composer/installers": true, + "roots/wordpress-core-installer": true + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "installer-paths": { + "web/app/mu-plugins/{$name}/": [ + "type:wordpress-muplugin" + ], + "web/app/plugins/{$name}/": [ + "type:wordpress-plugin" + ], + "web/app/themes/{$name}/": [ + "type:wordpress-theme" + ] + }, + "wordpress-install-dir": "web/wp" + }, + "scripts": { + "lint": "pint --test", + "lint:fix": "pint", + "security:audit": "composer audit" + }, + "suggest": { + "wpackagist-plugin/redis-cache": "Redis-backed object cache plugin (enable WP_CACHE + WP_REDIS_* env vars)", + "humanmade/s3-uploads": "S3-compatible uploads offload (recommended for Kubernetes)" + } +} + diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..1ff30b6 --- /dev/null +++ b/composer.lock @@ -0,0 +1,1124 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "22ffc1078e6fe872ac93fd19e6532ef8", + "packages": [ + { + "name": "composer/installers", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/composer/installers.git", + "reference": "12fb2dfe5e16183de69e784a7b84046c43d97e8e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/installers/zipball/12fb2dfe5e16183de69e784a7b84046c43d97e8e", + "reference": "12fb2dfe5e16183de69e784a7b84046c43d97e8e", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "composer/composer": "^1.10.27 || ^2.7", + "composer/semver": "^1.7.2 || ^3.4.0", + "phpstan/phpstan": "^1.11", + "phpstan/phpstan-phpunit": "^1", + "symfony/phpunit-bridge": "^7.1.1", + "symfony/process": "^5 || ^6 || ^7" + }, + "type": "composer-plugin", + "extra": { + "class": "Composer\\Installers\\Plugin", + "branch-alias": { + "dev-main": "2.x-dev" + }, + "plugin-modifies-install-path": true + }, + "autoload": { + "psr-4": { + "Composer\\Installers\\": "src/Composer/Installers" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kyle Robinson Young", + "email": "kyle@dontkry.com", + "homepage": "https://github.com/shama" + } + ], + "description": "A multi-framework Composer library installer", + "homepage": "https://composer.github.io/installers/", + "keywords": [ + "Dolibarr", + "Eliasis", + "Hurad", + "ImageCMS", + "Kanboard", + "Lan Management System", + "MODX Evo", + "MantisBT", + "Mautic", + "Maya", + "OXID", + "Plentymarkets", + "Porto", + "RadPHP", + "SMF", + "Starbug", + "Thelia", + "Whmcs", + "WolfCMS", + "agl", + "annotatecms", + "attogram", + "bitrix", + "cakephp", + "chef", + "cockpit", + "codeigniter", + "concrete5", + "concreteCMS", + "croogo", + "dokuwiki", + "drupal", + "eZ Platform", + "elgg", + "expressionengine", + "fuelphp", + "grav", + "installer", + "itop", + "known", + "kohana", + "laravel", + "lavalite", + "lithium", + "magento", + "majima", + "mako", + "matomo", + "mediawiki", + "miaoxing", + "modulework", + "modx", + "moodle", + "osclass", + "pantheon", + "phpbb", + "piwik", + "ppi", + "processwire", + "puppet", + "pxcms", + "reindex", + "roundcube", + "shopware", + "silverstripe", + "sydes", + "sylius", + "tastyigniter", + "wordpress", + "yawik", + "zend", + "zikula" + ], + "support": { + "issues": "https://github.com/composer/installers/issues", + "source": "https://github.com/composer/installers/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-06-24T20:46:46+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:43:20+00:00" + }, + { + "name": "oscarotero/env", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/oscarotero/env.git", + "reference": "9f7d85cc6890f06a65bad4fe0077c070d596e4a4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/oscarotero/env/zipball/9f7d85cc6890f06a65bad4fe0077c070d596e4a4", + "reference": "9f7d85cc6890f06a65bad4fe0077c070d596e4a4", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": ">=7.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.16", + "phpunit/phpunit": ">=7.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/env_function.php" + ], + "psr-4": { + "Env\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oscar Otero", + "email": "oom@oscarotero.com", + "homepage": "http://oscarotero.com", + "role": "Developer" + } + ], + "description": "Simple library to consume environment variables", + "homepage": "https://github.com/oscarotero/env", + "keywords": [ + "env" + ], + "support": { + "email": "oom@oscarotero.com", + "issues": "https://github.com/oscarotero/env/issues", + "source": "https://github.com/oscarotero/env/tree/v2.1.1" + }, + "time": "2024-12-03T01:02:28+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "roots/bedrock-autoloader", + "version": "1.0.4", + "source": { + "type": "git", + "url": "https://github.com/roots/bedrock-autoloader.git", + "reference": "f508348a3365ab5ce7e045f5fd4ee9f0a30dd70f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/roots/bedrock-autoloader/zipball/f508348a3365ab5ce7e045f5fd4ee9f0a30dd70f", + "reference": "f508348a3365ab5ce7e045f5fd4ee9f0a30dd70f", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "10up/wp_mock": "^0.4.2", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Roots\\Bedrock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nick Fox", + "email": "nick@foxaii.com", + "homepage": "https://github.com/foxaii" + }, + { + "name": "Scott Walkinshaw", + "email": "scott.walkinshaw@gmail.com", + "homepage": "https://github.com/swalkinshaw" + }, + { + "name": "Austin Pray", + "email": "austin@austinpray.com", + "homepage": "https://github.com/austinpray" + } + ], + "description": "An autoloader that enables standard plugins to be required just like must-use plugins", + "keywords": [ + "autoloader", + "bedrock", + "mu-plugin", + "must-use", + "plugin", + "wordpress" + ], + "support": { + "forum": "https://discourse.roots.io/", + "issues": "https://github.com/roots/bedrock-autoloader/issues", + "source": "https://github.com/roots/bedrock-autoloader/tree/1.0.4" + }, + "funding": [ + { + "url": "https://github.com/roots", + "type": "github" + }, + { + "url": "https://www.patreon.com/rootsdev", + "type": "patreon" + } + ], + "time": "2020-12-04T15:59:12+00:00" + }, + { + "name": "roots/bedrock-disallow-indexing", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/roots/bedrock-disallow-indexing.git", + "reference": "6c28192e17cb9e02a5c0c99691a18552b85e1615" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/roots/bedrock-disallow-indexing/zipball/6c28192e17cb9e02a5c0c99691a18552b85e1615", + "reference": "6c28192e17cb9e02a5c0c99691a18552b85e1615", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "wordpress-muplugin", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Word", + "email": "ben@benword.com", + "homepage": "https://github.com/retlehs" + }, + { + "name": "Scott Walkinshaw", + "email": "scott.walkinshaw@gmail.com", + "homepage": "https://github.com/swalkinshaw" + }, + { + "name": "QWp6t", + "email": "hi@qwp6t.me", + "homepage": "https://github.com/qwp6t" + } + ], + "description": "Disallow indexing of your site on non-production environments", + "keywords": [ + "wordpress" + ], + "support": { + "forum": "https://discourse.roots.io/", + "issues": "https://github.com/roots/bedrock-disallow-indexing/issues", + "source": "https://github.com/roots/bedrock-disallow-indexing/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/roots", + "type": "github" + }, + { + "url": "https://www.patreon.com/rootsdev", + "type": "patreon" + } + ], + "time": "2020-05-20T01:25:07+00:00" + }, + { + "name": "roots/wordpress", + "version": "6.9.1", + "source": { + "type": "git", + "url": "https://github.com/roots/wordpress.git", + "reference": "29e4eb49b2f4c591e39d4eb6705a27cf1ea40e45" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/roots/wordpress/zipball/29e4eb49b2f4c591e39d4eb6705a27cf1ea40e45", + "reference": "29e4eb49b2f4c591e39d4eb6705a27cf1ea40e45", + "shasum": "" + }, + "require": { + "roots/wordpress-core-installer": "^3.0", + "roots/wordpress-no-content": "self.version" + }, + "type": "metapackage", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT", + "GPL-2.0-or-later" + ], + "description": "WordPress is open source software you can use to create a beautiful website, blog, or app.", + "homepage": "https://wordpress.org/", + "keywords": [ + "blog", + "cms", + "wordpress" + ], + "support": { + "issues": "https://github.com/roots/wordpress/issues", + "source": "https://github.com/roots/wordpress/tree/6.9.1" + }, + "funding": [ + { + "url": "https://github.com/roots", + "type": "github" + } + ], + "time": "2025-05-23T18:54:22+00:00" + }, + { + "name": "roots/wordpress-core-installer", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/roots/wordpress-core-installer.git", + "reference": "714d2e2a9e523f6e7bde4810d5a04aedf0ec217f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/roots/wordpress-core-installer/zipball/714d2e2a9e523f6e7bde4810d5a04aedf0ec217f", + "reference": "714d2e2a9e523f6e7bde4810d5a04aedf0ec217f", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0", + "php": ">=7.2.24" + }, + "conflict": { + "composer/installers": "<1.0.6" + }, + "replace": { + "johnpbloch/wordpress-core-installer": "*" + }, + "require-dev": { + "composer/composer": "^1.0 || ^2.0", + "phpunit/phpunit": "^8.5" + }, + "type": "composer-plugin", + "extra": { + "class": "Roots\\Composer\\WordPressCorePlugin" + }, + "autoload": { + "psr-4": { + "Roots\\Composer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "John P. Bloch", + "email": "me@johnpbloch.com" + }, + { + "name": "Roots", + "email": "team@roots.io" + } + ], + "description": "A Composer custom installer to handle installing WordPress as a dependency", + "keywords": [ + "wordpress" + ], + "support": { + "issues": "https://github.com/roots/wordpress-core-installer/issues", + "source": "https://github.com/roots/wordpress-core-installer/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/roots", + "type": "github" + } + ], + "time": "2025-05-23T18:47:25+00:00" + }, + { + "name": "roots/wordpress-no-content", + "version": "6.9.1", + "source": { + "type": "git", + "url": "https://github.com/WordPress/WordPress.git", + "reference": "6.9.1" + }, + "dist": { + "type": "zip", + "url": "https://downloads.wordpress.org/release/wordpress-6.9.1-no-content.zip", + "reference": "6.9.1", + "shasum": "27121719e788b7c9e35cfcf8984ad8b42bd423d6" + }, + "require": { + "php": ">= 7.2.24" + }, + "provide": { + "wordpress/core-implementation": "6.9.1" + }, + "suggest": { + "ext-curl": "Performs remote request operations.", + "ext-dom": "Used to validate Text Widget content and to automatically configuring IIS7+.", + "ext-exif": "Works with metadata stored in images.", + "ext-fileinfo": "Used to detect mimetype of file uploads.", + "ext-hash": "Used for hashing, including passwords and update packages.", + "ext-imagick": "Provides better image quality for media uploads.", + "ext-json": "Used for communications with other servers.", + "ext-libsodium": "Validates Signatures and provides securely random bytes.", + "ext-mbstring": "Used to properly handle UTF8 text.", + "ext-mysqli": "Connects to MySQL for database interactions.", + "ext-openssl": "Permits SSL-based connections to other hosts.", + "ext-pcre": "Increases performance of pattern matching in code searches.", + "ext-xml": "Used for XML parsing, such as from a third-party site.", + "ext-zip": "Used for decompressing Plugins, Themes, and WordPress update packages." + }, + "type": "wordpress-core", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "WordPress Community", + "homepage": "https://wordpress.org/about/" + } + ], + "description": "WordPress is open source software you can use to create a beautiful website, blog, or app.", + "homepage": "https://wordpress.org/", + "keywords": [ + "blog", + "cms", + "wordpress" + ], + "support": { + "docs": "https://developer.wordpress.org/", + "forum": "https://wordpress.org/support/", + "irc": "irc://irc.freenode.net/wordpress", + "issues": "https://core.trac.wordpress.org/", + "rss": "https://wordpress.org/news/feed/", + "source": "https://core.trac.wordpress.org/browser", + "wiki": "https://codex.wordpress.org/" + }, + "funding": [ + { + "url": "https://wordpressfoundation.org/donate/", + "type": "other" + } + ], + "time": "2026-02-03T17:39:17+00:00" + }, + { + "name": "roots/wp-config", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/roots/wp-config.git", + "reference": "37c38230796119fb487fa03346ab0706ce6d4962" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/roots/wp-config/zipball/37c38230796119fb487fa03346ab0706ce6d4962", + "reference": "37c38230796119fb487fa03346ab0706ce6d4962", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5.7", + "roave/security-advisories": "dev-master", + "squizlabs/php_codesniffer": "^3.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Roots\\WPConfig\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Austin Pray", + "email": "austin@austinpray.com" + } + ], + "description": "Collect configuration values and safely define() them", + "support": { + "issues": "https://github.com/roots/wp-config/issues", + "source": "https://github.com/roots/wp-config/tree/master" + }, + "time": "2018-08-10T14:18:38+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.3", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "955e7815d677a3eaa7075231212f2110983adecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:49:13+00:00" + } + ], + "packages-dev": [ + { + "name": "laravel/pint", + "version": "v1.27.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/54cca2de13790570c7b6f0f94f37896bee4abcb5", + "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.93.1", + "illuminate/view": "^12.51.0", + "larastan/larastan": "^3.9.2", + "laravel-zero/framework": "^12.0.5", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.3.3", + "pestphp/pest": "^3.8.5" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-02-10T20:00:20+00:00" + } + ], + "aliases": [], + "minimum-stability": "dev", + "stability-flags": [], + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": ">=8.2" + }, + "platform-dev": [], + "plugin-api-version": "2.6.0" +} diff --git a/config/application.php b/config/application.php new file mode 100644 index 0000000..6ecd565 --- /dev/null +++ b/config/application.php @@ -0,0 +1,203 @@ +addAdapter(Dotenv\Repository\Adapter\EnvConstAdapter::class) + ->addAdapter(Dotenv\Repository\Adapter\PutenvAdapter::class) + ->immutable() + ->make(); + + $dotenv = Dotenv\Dotenv::create($repository, $root_dir, $env_files, false); + $dotenv->load(); + + $dotenv->required(['WP_HOME', 'WP_SITEURL']); + if (!env('DATABASE_URL')) { + $dotenv->required(['DB_NAME', 'DB_USER', 'DB_PASSWORD']); + } +} + +/** + * Set up our global environment constant. + * + * Default: production + */ +define('WP_ENV', env('WP_ENV') ?: 'production'); + +/** + * Infer WP_ENVIRONMENT_TYPE based on WP_ENV. + */ +if (!env('WP_ENVIRONMENT_TYPE') && in_array(WP_ENV, ['production', 'staging', 'development', 'local'], true)) { + Config::define('WP_ENVIRONMENT_TYPE', WP_ENV); +} + +/** + * URLs + */ +Config::define('WP_HOME', env('WP_HOME')); +Config::define('WP_SITEURL', env('WP_SITEURL')); + +/** + * Custom content directory + */ +Config::define('CONTENT_DIR', '/app'); +Config::define('WP_CONTENT_DIR', $webroot_dir . Config::get('CONTENT_DIR')); +Config::define('WP_CONTENT_URL', Config::get('WP_HOME') . Config::get('CONTENT_DIR')); + +/** + * Database + */ +if (env('DB_SSL')) { + Config::define('MYSQL_CLIENT_FLAGS', MYSQLI_CLIENT_SSL); +} + +Config::define('DB_NAME', env('DB_NAME')); +Config::define('DB_USER', env('DB_USER')); +Config::define('DB_PASSWORD', env('DB_PASSWORD')); +Config::define('DB_HOST', env('DB_HOST') ?: 'localhost'); +Config::define('DB_CHARSET', 'utf8mb4'); +Config::define('DB_COLLATE', ''); + +$table_prefix = env('DB_PREFIX') ?: 'wp_'; + +if (env('DATABASE_URL')) { + $dsn = (object) parse_url(env('DATABASE_URL')); + + Config::define('DB_NAME', substr($dsn->path, 1)); + Config::define('DB_USER', $dsn->user); + Config::define('DB_PASSWORD', isset($dsn->pass) ? $dsn->pass : null); + Config::define('DB_HOST', isset($dsn->port) ? "{$dsn->host}:{$dsn->port}" : $dsn->host); +} + +/** + * Authentication Unique Keys and Salts + */ +Config::define('AUTH_KEY', env('AUTH_KEY')); +Config::define('SECURE_AUTH_KEY', env('SECURE_AUTH_KEY')); +Config::define('LOGGED_IN_KEY', env('LOGGED_IN_KEY')); +Config::define('NONCE_KEY', env('NONCE_KEY')); +Config::define('AUTH_SALT', env('AUTH_SALT')); +Config::define('SECURE_AUTH_SALT', env('SECURE_AUTH_SALT')); +Config::define('LOGGED_IN_SALT', env('LOGGED_IN_SALT')); +Config::define('NONCE_SALT', env('NONCE_SALT')); + +/** + * Cloud-native / optional integrations + */ + +// Object cache (Redis) - plugin required (e.g. redis-cache). +Config::define('WP_CACHE', env('WP_CACHE') ?? false); +Config::define('WP_REDIS_HOST', env('WP_REDIS_HOST') ?: null); +Config::define('WP_REDIS_PORT', env('WP_REDIS_PORT') ?: 6379); +Config::define('WP_REDIS_PASSWORD', env('WP_REDIS_PASSWORD') ?: null); +Config::define('WP_REDIS_DATABASE', env('WP_REDIS_DATABASE') ?: 0); +Config::define('WP_REDIS_PREFIX', env('WP_REDIS_PREFIX') ?: $table_prefix); + +// Uploads offload (S3-compatible) - plugin required (e.g. humanmade/s3-uploads). +if ($bucket = env('S3_UPLOADS_BUCKET')) { + Config::define('S3_UPLOADS_BUCKET', $bucket); + + foreach ([ + 'S3_UPLOADS_REGION', + 'S3_UPLOADS_BUCKET_URL', + 'S3_UPLOADS_KEY', + 'S3_UPLOADS_SECRET', + 'S3_UPLOADS_ENDPOINT', + 'S3_UPLOADS_PATH_STYLE_ENDPOINT', + 'S3_UPLOADS_USE_INSTANCE_PROFILE', + ] as $key) { + $value = env($key); + if ($value !== null && $value !== '') { + Config::define($key, $value); + } + } +} + +/** + * Custom settings / hardening + */ +Config::define('AUTOMATIC_UPDATER_DISABLED', true); +Config::define('DISABLE_WP_CRON', env('DISABLE_WP_CRON') ?: false); + +// Default theme shipped in this repository (can be overridden by env var). +Config::define('WP_DEFAULT_THEME', env('WP_DEFAULT_THEME') ?: 'starter-theme'); + +// Disable the plugin and theme file editor in the admin. +Config::define('DISALLOW_FILE_EDIT', true); + +// Disable plugin and theme updates and installation from the admin by default. +// Override in development/staging config if needed. +Config::define('DISALLOW_FILE_MODS', env('DISALLOW_FILE_MODS') ?? true); + +// Limit the number of post revisions (avoid unbounded revision growth by default). +Config::define('WP_POST_REVISIONS', env('WP_POST_REVISIONS') ?? 25); + +// Disable script concatenation (avoid surprises behind CDNs/proxies). +Config::define('CONCATENATE_SCRIPTS', false); + +/** + * Debugging defaults (production-minded). + */ +Config::define('WP_DEBUG_DISPLAY', false); +Config::define('WP_DEBUG_LOG', false); +Config::define('SCRIPT_DEBUG', false); +ini_set('display_errors', '0'); + +/** + * Allow WordPress to detect HTTPS when used behind a reverse proxy/load balancer. + * See: https://codex.wordpress.org/Function_Reference/is_ssl#Notes + */ +if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') { + $_SERVER['HTTPS'] = 'on'; +} + +$env_config = __DIR__ . '/environments/' . WP_ENV . '.php'; +if (file_exists($env_config)) { + require_once $env_config; +} + +Config::apply(); + +/** + * Bootstrap WordPress + */ +if (!defined('ABSPATH')) { + define('ABSPATH', $webroot_dir . '/wp/'); +} diff --git a/config/environments/development.php b/config/environments/development.php new file mode 100644 index 0000000..b14e803 --- /dev/null +++ b/config/environments/development.php @@ -0,0 +1,22 @@ + sealedsecret-wordpress.yaml + +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: wp-secrets + namespace: wp +spec: + encryptedData: + DB_PASSWORD: REPLACE_WITH_KUBESEAL_OUTPUT + AUTH_KEY: REPLACE_WITH_KUBESEAL_OUTPUT + SECURE_AUTH_KEY: REPLACE_WITH_KUBESEAL_OUTPUT + LOGGED_IN_KEY: REPLACE_WITH_KUBESEAL_OUTPUT + NONCE_KEY: REPLACE_WITH_KUBESEAL_OUTPUT + AUTH_SALT: REPLACE_WITH_KUBESEAL_OUTPUT + SECURE_AUTH_SALT: REPLACE_WITH_KUBESEAL_OUTPUT + LOGGED_IN_SALT: REPLACE_WITH_KUBESEAL_OUTPUT + NONCE_SALT: REPLACE_WITH_KUBESEAL_OUTPUT + template: + metadata: + name: wp-secrets + namespace: wp + type: Opaque + diff --git a/docs/kubernetes/secrets-required.md b/docs/kubernetes/secrets-required.md new file mode 100644 index 0000000..fb4ffbf --- /dev/null +++ b/docs/kubernetes/secrets-required.md @@ -0,0 +1,50 @@ +# Required Secrets / Env Vars (Bedrock) + +This boilerplate follows Bedrock's environment-variable configuration model. + +## Required env vars (ConfigMap) + +Typically stored in a ConfigMap (non-secret): + +- `WP_ENV` +- `WP_HOME` +- `WP_SITEURL` +- `DB_HOST` +- `DB_NAME` +- `DB_USER` +- `DB_PREFIX` (optional) + +## Required secret keys (Secret) + +Store these in a Kubernetes Secret (or via External Secrets / SealedSecrets): + +### Database + +- `DB_PASSWORD` + +### WordPress salts/keys + +- `AUTH_KEY` +- `SECURE_AUTH_KEY` +- `LOGGED_IN_KEY` +- `NONCE_KEY` +- `AUTH_SALT` +- `SECURE_AUTH_SALT` +- `LOGGED_IN_SALT` +- `NONCE_SALT` + +Generate secure salts: + +- https://roots.io/salts.html + +## Optional secret keys + +- `WP_REDIS_PASSWORD` (if your Redis requires auth) + +## Helm chart integration + +The Helm chart supports: + +- `secret.existingSecret`: reference a Secret created elsewhere (recommended) +- `secret.create=true` + `secret.data`: creates a Secret from Helm values (not recommended for real environments) + diff --git a/docs/kubernetes/uploads-s3.md b/docs/kubernetes/uploads-s3.md new file mode 100644 index 0000000..2d8d012 --- /dev/null +++ b/docs/kubernetes/uploads-s3.md @@ -0,0 +1,71 @@ +# Uploads Offload to S3-Compatible Object Storage (Recommended) + +In Kubernetes, scaling WordPress beyond 1 replica usually requires avoiding a shared filesystem for uploads. + +Recommended approach: **offload uploads to an S3-compatible bucket**. + +## Plugin option: humanmade/s3-uploads + +One popular open-source option: + +- https://github.com/humanmade/S3-Uploads + +Install (Bedrock / Composer): + +```bash +composer require humanmade/s3-uploads +``` + +> The plugin reads configuration from PHP constants. This boilerplate can define +> those constants from environment variables (see `config/application.php`). + +## Env vars / constants (typical) + +At minimum: + +- `S3_UPLOADS_BUCKET` +- `S3_UPLOADS_REGION` (AWS) or omit for some S3-compatible providers + +Auth options: + +- Key/secret in a Secret: + - `S3_UPLOADS_KEY` + - `S3_UPLOADS_SECRET` +- Or workload identity / instance profile: + - `S3_UPLOADS_USE_INSTANCE_PROFILE=true` + +S3-compatible endpoints (MinIO, Ceph, etc): + +- `S3_UPLOADS_ENDPOINT=https://minio.example.com` +- `S3_UPLOADS_PATH_STYLE_ENDPOINT=true` + +Public bucket URL (CDN or direct): + +- `S3_UPLOADS_BUCKET_URL=https://cdn.example.com` + +## Helm integration + +1. Keep uploads PVC disabled: + +```yaml +uploads: + persistence: + enabled: false +``` + +2. Provide env vars via a ConfigMap/Secret you manage, then reference them: + +```yaml +config: + existingConfigMap: wp-env +secret: + existingSecret: wp-secrets +``` + +See also: + +- `docs/kubernetes/configmap-bedrock.example.yaml` +- `docs/kubernetes/secrets-required.md` +- `docs/kubernetes/external-secrets/` +- `docs/kubernetes/sealed-secrets/` + diff --git a/docs/local-dev/tls-mkcert.md b/docs/local-dev/tls-mkcert.md new file mode 100644 index 0000000..8432836 --- /dev/null +++ b/docs/local-dev/tls-mkcert.md @@ -0,0 +1,54 @@ +# Trusted Local TLS with mkcert + +This repo supports trusted local HTTPS with: + +- `mkcert` for certificate generation +- Caddy profile `tls-trusted` for reverse proxy + +## Why + +The default TLS profile uses Caddy internal certs (quick setup, browser warning). +For day-to-day local dev (cookies, OAuth callbacks, browser APIs), trusted certs are better. + +## Prerequisites + +- `mkcert` installed + - macOS (Homebrew): `brew install mkcert nss` + - Linux: see https://github.com/FiloSottile/mkcert#linux + - Windows (Chocolatey): `choco install mkcert` + +## Steps + +1. Configure `.env`: + +```dotenv +WP_HOME=https://wp.localhost:8443 +WP_SITEURL=https://wp.localhost:8443/wp +``` + +2. Generate trusted certs: + +```bash +make certs-mkcert +``` + +This creates (gitignored): + +- `.certs/wp.localhost.pem` +- `.certs/wp.localhost-key.pem` + +3. Start stack: + +```bash +make bootstrap-tls-trusted +``` + +4. Open: + +- https://wp.localhost:8443 + +## Troubleshooting + +- If you still see cert warnings, re-run `mkcert -install` and restart your browser. +- Ensure your OS trust store accepted mkcert's local CA. + diff --git a/docs/supply-chain/README.md b/docs/supply-chain/README.md new file mode 100644 index 0000000..0d22235 --- /dev/null +++ b/docs/supply-chain/README.md @@ -0,0 +1,12 @@ +# Supply Chain & Image Verification + +This repository aims to be **build-once / deploy-everywhere**: + +- CI builds container images and publishes to GHCR +- Deployments should pin images **by digest** +- CI **signs** published images using **Cosign (keyless, GitHub OIDC)** + +See: + +- `docs/supply-chain/cosign.md` + diff --git a/docs/supply-chain/cosign.md b/docs/supply-chain/cosign.md new file mode 100644 index 0000000..153d768 --- /dev/null +++ b/docs/supply-chain/cosign.md @@ -0,0 +1,51 @@ +# Cosign (Keyless) Verification + +The container build workflow signs published images using **Cosign** with **GitHub OIDC** (no long-lived signing keys in the repo). + +Workflow: + +- `.github/workflows/container-build.yml` + +## Verify a published image + +Install cosign: + +- https://docs.sigstore.dev/cosign/system_config/installation/ + +Then verify an image digest: + +```bash +cosign verify \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity "https://github.com///.github/workflows/container-build.yml@refs/heads/" \ + ghcr.io//-php@sha256: +``` + +Repeat for the nginx image: + +```bash +cosign verify \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity "https://github.com///.github/workflows/container-build.yml@refs/heads/" \ + ghcr.io//-nginx@sha256: +``` + +Notes: + +- Images are only published/signed on non-PR events in the workflow (PRs build but do not push). +- Prefer verifying **by digest**, not by tag. + +## SBOM / provenance (optional) + +The workflow also publishes SBOM/provenance attestations via BuildKit. + +Depending on your cosign version and registry support, you can fetch them: + +```bash +cosign download sbom ghcr.io//-php@sha256: +``` + +```bash +cosign download attestation ghcr.io//-php@sha256: +``` + diff --git a/helm/wp-boilerplate/.helmignore b/helm/wp-boilerplate/.helmignore new file mode 100644 index 0000000..4766564 --- /dev/null +++ b/helm/wp-boilerplate/.helmignore @@ -0,0 +1,5 @@ +.DS_Store +.git/ +.idea/ +.vscode/ + diff --git a/helm/wp-boilerplate/Chart.yaml b/helm/wp-boilerplate/Chart.yaml new file mode 100644 index 0000000..2977dc1 --- /dev/null +++ b/helm/wp-boilerplate/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v2 +name: wp-boilerplate +description: Cloud-native WordPress (Bedrock) boilerplate +type: application +version: 0.1.0 +appVersion: "0.1.0" + diff --git a/helm/wp-boilerplate/README.md b/helm/wp-boilerplate/README.md new file mode 100644 index 0000000..62305ff --- /dev/null +++ b/helm/wp-boilerplate/README.md @@ -0,0 +1,106 @@ +# wp-boilerplate Helm Chart + +This chart deploys the boilerplate as a hardened, Kubernetes-native workload: + +- **Nginx** (non-root) serving Bedrock `web/` +- **PHP-FPM** (non-root) running WordPress +- Shared **FPM socket** via `emptyDir` +- Optional **uploads PVC** (or offload uploads to object storage) +- Optional **HPA / PDB / NetworkPolicy** +- Optional **CronJob** to replace WP-Cron + +## Quick install + +> Recommended: deploy **by image digest** (build once, deploy everywhere). + +```bash +helm upgrade --install wp ./helm/wp-boilerplate \ + --namespace wp --create-namespace \ + --set image.php.repository=ghcr.io/OWNER/REPO-php \ + --set image.web.repository=ghcr.io/OWNER/REPO-nginx \ + --set image.php.digest=sha256:... \ + --set image.web.digest=sha256:... +``` + +## Configuration (ConfigMap + Secret) + +The app uses Bedrock-style env vars. + +This chart creates a ConfigMap (`-env`) unless `config.existingConfigMap` is provided. + +### Secrets + +For production, prefer **External Secrets** or **SealedSecrets**. + +You can either: + +- Provide `secret.existingSecret`, or +- Set `secret.create=true` and populate `secret.data` (not recommended) + +Examples: + +- `docs/kubernetes/external-secrets/` +- `docs/kubernetes/sealed-secrets/` +- `docs/kubernetes/secrets-required.md` + +Expected Secret keys include: + +- `DB_PASSWORD` +- `AUTH_KEY`, `SECURE_AUTH_KEY`, `LOGGED_IN_KEY`, `NONCE_KEY` +- `AUTH_SALT`, `SECURE_AUTH_SALT`, `LOGGED_IN_SALT`, `NONCE_SALT` + +## Uploads storage strategy + +### Recommended (cloud-native): S3-compatible offload + +Use a uploads offload plugin (example: `humanmade/s3-uploads`) so you can scale replicas without shared storage. + +See: `docs/kubernetes/uploads-s3.md` + +### Alternative: PVC + +Set: + +```yaml +uploads: + persistence: + enabled: true + size: 10Gi +``` + +If you scale above 1 replica without a shared RWX filesystem, uploads consistency will break. + +## WP-Cron + +Production guidance: + +- Set `DISABLE_WP_CRON=true` +- Enable the chart CronJob: + +```yaml +cron: + enabled: true + schedule: "*/5 * * * *" +``` + +If your cluster uses a non-default DNS domain, set: + +```yaml +clusterDomain: cluster.local +``` + +## Metrics (optional) + +This chart can run a `php-fpm_exporter` sidecar (Prometheus) and optionally create a `ServiceMonitor`. + +Example: + +```yaml +metrics: + enabled: true + serviceMonitor: + enabled: true + labels: + release: prometheus +``` + diff --git a/helm/wp-boilerplate/templates/_helpers.tpl b/helm/wp-boilerplate/templates/_helpers.tpl new file mode 100644 index 0000000..8533e1a --- /dev/null +++ b/helm/wp-boilerplate/templates/_helpers.tpl @@ -0,0 +1,48 @@ +{{- define "wp-boilerplate.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "wp-boilerplate.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := include "wp-boilerplate.name" . -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- define "wp-boilerplate.labels" -}} +app.kubernetes.io/name: {{ include "wp-boilerplate.name" . }} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "wp-boilerplate.selectorLabels" -}} +app.kubernetes.io/name: {{ include "wp-boilerplate.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{- define "wp-boilerplate.serviceAccountName" -}} +{{- if .Values.serviceAccount.create -}} +{{- default (include "wp-boilerplate.fullname" .) .Values.serviceAccount.name -}} +{{- else -}} +{{- default "default" .Values.serviceAccount.name -}} +{{- end -}} +{{- end -}} + +{{- define "wp-boilerplate.imageRef" -}} +{{- $img := . -}} +{{- if $img.digest -}} +{{- printf "%s@%s" $img.repository $img.digest -}} +{{- else if $img.tag -}} +{{- printf "%s:%s" $img.repository $img.tag -}} +{{- else -}} +{{- fail "image.tag or image.digest must be set (prefer digest for build-once deploy-everywhere)" -}} +{{- end -}} +{{- end -}} + diff --git a/helm/wp-boilerplate/templates/configmap.yaml b/helm/wp-boilerplate/templates/configmap.yaml new file mode 100644 index 0000000..4d4ece0 --- /dev/null +++ b/helm/wp-boilerplate/templates/configmap.yaml @@ -0,0 +1,26 @@ +{{- if not .Values.config.existingConfigMap -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "wp-boilerplate.fullname" . }}-env + labels: + {{- include "wp-boilerplate.labels" . | nindent 4 }} +data: + WP_ENV: {{ .Values.env.WP_ENV | quote }} + WP_HOME: {{ .Values.env.WP_HOME | quote }} + WP_SITEURL: {{ .Values.env.WP_SITEURL | quote }} + + DISABLE_WP_CRON: {{ .Values.env.DISABLE_WP_CRON | quote }} + DISALLOW_FILE_MODS: {{ .Values.env.DISALLOW_FILE_MODS | quote }} + WP_CACHE: {{ .Values.env.WP_CACHE | quote }} + WP_DEFAULT_THEME: {{ .Values.env.WP_DEFAULT_THEME | quote }} + + WP_REDIS_HOST: {{ .Values.env.WP_REDIS_HOST | quote }} + WP_REDIS_PORT: {{ .Values.env.WP_REDIS_PORT | quote }} + + DB_HOST: {{ .Values.env.DB_HOST | quote }} + DB_NAME: {{ .Values.env.DB_NAME | quote }} + DB_USER: {{ .Values.env.DB_USER | quote }} + DB_PREFIX: {{ .Values.env.DB_PREFIX | quote }} +{{- end }} + diff --git a/helm/wp-boilerplate/templates/cronjob.yaml b/helm/wp-boilerplate/templates/cronjob.yaml new file mode 100644 index 0000000..d349496 --- /dev/null +++ b/helm/wp-boilerplate/templates/cronjob.yaml @@ -0,0 +1,42 @@ +{{- if .Values.cron.enabled -}} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "wp-boilerplate.fullname" . }}-cron + labels: + {{- include "wp-boilerplate.labels" . | nindent 4 }} +spec: + schedule: {{ .Values.cron.schedule | quote }} + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + template: + metadata: + labels: + {{- include "wp-boilerplate.selectorLabels" . | nindent 12 }} + spec: + restartPolicy: OnFailure + securityContext: + seccompProfile: + type: RuntimeDefault + containers: + - name: wp-cron + image: {{ printf "%s:%s" .Values.cron.image.repository .Values.cron.image.tag }} + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: ["ALL"] + command: + - /bin/sh + - -c + - > + curl -fsSL + "http://{{ include "wp-boilerplate.fullname" . }}.{{ .Release.Namespace }}.svc.{{ .Values.clusterDomain }}:{{ .Values.service.port }}{{ .Values.cron.urlPath }}" + >/dev/null +{{- end }} + diff --git a/helm/wp-boilerplate/templates/deployment.yaml b/helm/wp-boilerplate/templates/deployment.yaml new file mode 100644 index 0000000..8942eac --- /dev/null +++ b/helm/wp-boilerplate/templates/deployment.yaml @@ -0,0 +1,138 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "wp-boilerplate.fullname" . }} + labels: + {{- include "wp-boilerplate.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "wp-boilerplate.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "wp-boilerplate.selectorLabels" . | nindent 8 }} + annotations: + {{- toYaml .Values.podAnnotations | nindent 8 }} + spec: + serviceAccountName: {{ include "wp-boilerplate.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + volumes: + - name: php-socket + emptyDir: {} + - name: tmp + emptyDir: + medium: Memory + - name: nginx-cache + emptyDir: {} + - name: uploads +{{ if .Values.uploads.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ default (printf "%s-uploads" (include "wp-boilerplate.fullname" .)) .Values.uploads.persistence.existingClaim }} +{{ else }} + emptyDir: {} +{{ end }} + - name: app-cache + emptyDir: {} + + containers: + - name: web + image: {{ include "wp-boilerplate.imageRef" .Values.image.web }} + imagePullPolicy: {{ .Values.image.web.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + securityContext: + {{- toYaml .Values.securityContext.web | nindent 12 }} + volumeMounts: + - name: php-socket + mountPath: /var/run/php + - name: tmp + mountPath: /tmp + - name: nginx-cache + mountPath: /var/cache/nginx + - name: uploads + mountPath: /var/www/html/web/app/uploads + - name: app-cache + mountPath: /var/www/html/web/app/cache + readinessProbe: + httpGet: + path: /ping + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 6 + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 6 + resources: + {{- toYaml .Values.resources.web | nindent 12 }} + + - name: php + image: {{ include "wp-boilerplate.imageRef" .Values.image.php }} + imagePullPolicy: {{ .Values.image.php.pullPolicy }} + securityContext: + {{- toYaml .Values.securityContext.php | nindent 12 }} + envFrom: + - configMapRef: + name: {{ default (printf "%s-env" (include "wp-boilerplate.fullname" .)) .Values.config.existingConfigMap }} + {{- if .Values.secret.existingSecret }} + - secretRef: + name: {{ .Values.secret.existingSecret }} + {{- else if .Values.secret.create }} + - secretRef: + name: {{ include "wp-boilerplate.fullname" . }}-secret + {{- end }} + env: + {{- range .Values.env.extra }} + - name: {{ .name }} + value: {{ .value | quote }} + {{- end }} + volumeMounts: + - name: php-socket + mountPath: /var/run/php + - name: tmp + mountPath: /tmp + - name: uploads + mountPath: /var/www/html/web/app/uploads + - name: app-cache + mountPath: /var/www/html/web/app/cache + resources: + {{- toYaml .Values.resources.php | nindent 12 }} + + {{- if .Values.metrics.enabled }} + - name: php-fpm-exporter + image: "{{ .Values.metrics.phpFpmExporter.image }}:{{ .Values.metrics.phpFpmExporter.tag }}" + imagePullPolicy: {{ .Values.metrics.phpFpmExporter.pullPolicy }} + ports: + - name: metrics + containerPort: {{ .Values.metrics.phpFpmExporter.port }} + env: + - name: PHP_FPM_SCRAPE_URI + value: "unix:///var/run/php/php-fpm.sock;/status" + - name: PHP_FPM_FIX_PROCESS_COUNT + value: "true" + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 101 + runAsGroup: 101 + capabilities: + drop: ["ALL"] + volumeMounts: + - name: php-socket + mountPath: /var/run/php + readOnly: true + resources: + {{- toYaml .Values.metrics.phpFpmExporter.resources | nindent 12 }} + {{- end }} + diff --git a/helm/wp-boilerplate/templates/hpa.yaml b/helm/wp-boilerplate/templates/hpa.yaml new file mode 100644 index 0000000..2ab5e77 --- /dev/null +++ b/helm/wp-boilerplate/templates/hpa.yaml @@ -0,0 +1,23 @@ +{{- if .Values.autoscaling.enabled -}} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "wp-boilerplate.fullname" . }} + labels: + {{- include "wp-boilerplate.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "wp-boilerplate.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} +{{- end }} + diff --git a/helm/wp-boilerplate/templates/ingress.yaml b/helm/wp-boilerplate/templates/ingress.yaml new file mode 100644 index 0000000..a451478 --- /dev/null +++ b/helm/wp-boilerplate/templates/ingress.yaml @@ -0,0 +1,34 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "wp-boilerplate.fullname" . }} + labels: + {{- include "wp-boilerplate.labels" . | nindent 4 }} + annotations: + {{- toYaml .Values.ingress.annotations | nindent 4 }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- toYaml .Values.ingress.tls | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path | quote }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "wp-boilerplate.fullname" $ }} + port: + name: http + {{- end }} + {{- end }} +{{- end }} + diff --git a/helm/wp-boilerplate/templates/networkpolicy.yaml b/helm/wp-boilerplate/templates/networkpolicy.yaml new file mode 100644 index 0000000..70d9d04 --- /dev/null +++ b/helm/wp-boilerplate/templates/networkpolicy.yaml @@ -0,0 +1,22 @@ +{{- if .Values.networkPolicy.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "wp-boilerplate.fullname" . }} + labels: + {{- include "wp-boilerplate.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "wp-boilerplate.selectorLabels" . | nindent 6 }} + policyTypes: + - Ingress + - Egress + ingress: + - ports: + - protocol: TCP + port: {{ .Values.service.targetPort }} + egress: + - {} +{{- end }} + diff --git a/helm/wp-boilerplate/templates/pdb.yaml b/helm/wp-boilerplate/templates/pdb.yaml new file mode 100644 index 0000000..50577da --- /dev/null +++ b/helm/wp-boilerplate/templates/pdb.yaml @@ -0,0 +1,14 @@ +{{- if .Values.pdb.enabled -}} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "wp-boilerplate.fullname" . }} + labels: + {{- include "wp-boilerplate.labels" . | nindent 4 }} +spec: + minAvailable: {{ .Values.pdb.minAvailable }} + selector: + matchLabels: + {{- include "wp-boilerplate.selectorLabels" . | nindent 6 }} +{{- end }} + diff --git a/helm/wp-boilerplate/templates/pvc-uploads.yaml b/helm/wp-boilerplate/templates/pvc-uploads.yaml new file mode 100644 index 0000000..7bdbf5b --- /dev/null +++ b/helm/wp-boilerplate/templates/pvc-uploads.yaml @@ -0,0 +1,18 @@ +{{- if and .Values.uploads.persistence.enabled (not .Values.uploads.persistence.existingClaim) -}} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "wp-boilerplate.fullname" . }}-uploads + labels: + {{- include "wp-boilerplate.labels" . | nindent 4 }} +spec: + accessModes: + {{- toYaml .Values.uploads.persistence.accessModes | nindent 4 }} + resources: + requests: + storage: {{ .Values.uploads.persistence.size | quote }} + {{- if .Values.uploads.persistence.storageClass }} + storageClassName: {{ .Values.uploads.persistence.storageClass | quote }} + {{- end }} +{{- end }} + diff --git a/helm/wp-boilerplate/templates/secret.yaml b/helm/wp-boilerplate/templates/secret.yaml new file mode 100644 index 0000000..5f2877e --- /dev/null +++ b/helm/wp-boilerplate/templates/secret.yaml @@ -0,0 +1,14 @@ +{{- if and (not .Values.secret.existingSecret) .Values.secret.create -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "wp-boilerplate.fullname" . }}-secret + labels: + {{- include "wp-boilerplate.labels" . | nindent 4 }} +type: Opaque +stringData: +{{- range $k, $v := .Values.secret.data }} + {{ $k }}: {{ $v | quote }} +{{- end }} +{{- end }} + diff --git a/helm/wp-boilerplate/templates/service.yaml b/helm/wp-boilerplate/templates/service.yaml new file mode 100644 index 0000000..78f01da --- /dev/null +++ b/helm/wp-boilerplate/templates/service.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "wp-boilerplate.fullname" . }} + labels: + {{- include "wp-boilerplate.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + {{- include "wp-boilerplate.selectorLabels" . | nindent 4 }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + {{- if .Values.metrics.enabled }} + - name: metrics + port: {{ .Values.metrics.phpFpmExporter.port }} + targetPort: metrics + {{- end }} + diff --git a/helm/wp-boilerplate/templates/serviceaccount.yaml b/helm/wp-boilerplate/templates/serviceaccount.yaml new file mode 100644 index 0000000..9a89e8b --- /dev/null +++ b/helm/wp-boilerplate/templates/serviceaccount.yaml @@ -0,0 +1,9 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "wp-boilerplate.serviceAccountName" . }} + labels: + {{- include "wp-boilerplate.labels" . | nindent 4 }} +{{- end }} + diff --git a/helm/wp-boilerplate/templates/servicemonitor.yaml b/helm/wp-boilerplate/templates/servicemonitor.yaml new file mode 100644 index 0000000..c6a56a6 --- /dev/null +++ b/helm/wp-boilerplate/templates/servicemonitor.yaml @@ -0,0 +1,21 @@ +{{- if and .Values.metrics.enabled .Values.metrics.serviceMonitor.enabled -}} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "wp-boilerplate.fullname" . }} + labels: + {{- include "wp-boilerplate.labels" . | nindent 4 }} + {{- with .Values.metrics.serviceMonitor.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + matchLabels: + {{- include "wp-boilerplate.selectorLabels" . | nindent 6 }} + endpoints: + - port: metrics + path: /metrics + interval: {{ .Values.metrics.serviceMonitor.interval }} + scrapeTimeout: {{ .Values.metrics.serviceMonitor.scrapeTimeout }} +{{- end }} + diff --git a/helm/wp-boilerplate/values.yaml b/helm/wp-boilerplate/values.yaml new file mode 100644 index 0000000..a561327 --- /dev/null +++ b/helm/wp-boilerplate/values.yaml @@ -0,0 +1,161 @@ +replicaCount: 1 + +image: + php: + repository: ghcr.io/OWNER/REPO-php + tag: "" + digest: "" + pullPolicy: IfNotPresent + web: + repository: ghcr.io/OWNER/REPO-nginx + tag: "" + digest: "" + pullPolicy: IfNotPresent + +serviceAccount: + create: false + name: "" + +podAnnotations: {} + +podSecurityContext: + # Required for non-root containers writing to emptyDir/PVC mounts. + # 101 matches the nginx-unprivileged uid/gid and is added as a supplementary + # group for the PHP user in the provided image. + fsGroup: 101 + seccompProfile: + type: RuntimeDefault + +securityContext: + php: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 10001 + capabilities: + drop: ["ALL"] + web: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 101 + capabilities: + drop: ["ALL"] + +service: + type: ClusterIP + port: 80 + targetPort: 8080 + +# Kubernetes cluster DNS domain. +clusterDomain: cluster.local + +ingress: + enabled: false + className: "" + annotations: {} + hosts: + - host: wp.example.com + paths: + - path: / + pathType: Prefix + tls: [] + +resources: + php: {} + web: {} + +metrics: + enabled: false + phpFpmExporter: + image: hipages/php-fpm_exporter + tag: v2.2.0 + pullPolicy: IfNotPresent + port: 9253 + resources: {} + serviceMonitor: + enabled: false + interval: 30s + scrapeTimeout: 10s + labels: {} + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 10 + targetCPUUtilizationPercentage: 70 + +pdb: + enabled: false + minAvailable: 1 + +networkPolicy: + enabled: false + +env: + # Bedrock expects these env vars (do NOT store secrets in values.yaml). + WP_ENV: production + WP_HOME: https://wp.example.com + WP_SITEURL: https://wp.example.com/wp + + # Production guidance: disable WP-Cron and run a real scheduler. + DISABLE_WP_CRON: "true" + + # Production hardening (can be overridden for non-prod). + DISALLOW_FILE_MODS: "true" + WP_CACHE: "false" + WP_DEFAULT_THEME: starter-theme + + # Optional Redis object cache (requires a Redis cache plugin) + WP_REDIS_HOST: "" + WP_REDIS_PORT: "6379" + + # DB non-secret parts (password must come from a Secret) + DB_HOST: "" + DB_NAME: "" + DB_USER: "" + DB_PREFIX: wp_ + + extra: [] + # extra: + # - name: SOME_VAR + # value: "some-value" + +config: + # If set, chart will use this existing ConfigMap for env vars instead of creating one. + existingConfigMap: "" + +secret: + # Prefer External Secrets / SealedSecrets. This chart supports either: + # - referencing an existing Secret, or + # - creating a Secret from values (not recommended for real environments). + existingSecret: "" + create: false + data: {} + # data: + # DB_PASSWORD: "..." + # AUTH_KEY: "..." + # SECURE_AUTH_KEY: "..." + # LOGGED_IN_KEY: "..." + # NONCE_KEY: "..." + # AUTH_SALT: "..." + # SECURE_AUTH_SALT: "..." + # LOGGED_IN_SALT: "..." + # NONCE_SALT: "..." + +uploads: + persistence: + enabled: false + existingClaim: "" + storageClass: "" + accessModes: ["ReadWriteOnce"] + size: 10Gi + +cron: + enabled: false + schedule: "*/5 * * * *" + image: + repository: curlimages/curl + tag: "8.6.0" + urlPath: "/wp/wp-cron.php?doing_wp_cron" + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..226b242 --- /dev/null +++ b/pint.json @@ -0,0 +1,11 @@ +{ + "preset": "psr12", + "exclude": [ + "vendor", + "web/wp", + "web/app/mu-plugins", + "web/app/plugins", + "web/app/uploads" + ] +} + diff --git a/scripts/doctor.sh b/scripts/doctor.sh new file mode 100755 index 0000000..17d5486 --- /dev/null +++ b/scripts/doctor.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash + +set -euo pipefail + +errors=() +warnings=() + +add_error() { + errors+=("$1") +} + +add_warning() { + warnings+=("$1") +} + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + add_error "Missing required command: $cmd" + fi +} + +check_port_free() { + local port="$1" + local label="$2" + local severity="${3:-error}" + + if ! command -v ss >/dev/null 2>&1; then + add_warning "Cannot check port $port ($label): 'ss' command not found." + return + fi + + if ss -ltnH "sport = :$port" | grep -q .; then + if [[ "$severity" == "warning" ]]; then + add_warning "Port $port is already in use ($label)." + else + add_error "Port $port is already in use ($label)." + fi + fi +} + +print_section() { + local title="$1" + printf "\n== %s ==\n" "$title" +} + +print_section "WordPress Boilerplate Doctor" + +require_cmd docker +require_cmd make + +if command -v docker >/dev/null 2>&1; then + if ! docker info >/dev/null 2>&1; then + add_error "Docker daemon is not reachable (is Docker running?)." + fi + + if ! docker compose version >/dev/null 2>&1; then + add_error "Docker Compose v2 plugin is not available (docker compose)." + fi +fi + +if ! command -v mkcert >/dev/null 2>&1; then + add_warning "mkcert not found (only needed for trusted local TLS profile)." +fi + +if [[ ! -f ".env" ]]; then + add_warning ".env is missing. Run: cp .env.example .env" +else + wp_home="$(awk -F= '/^WP_HOME=/{print $2; exit}' .env | tr -d '\r' || true)" + wp_siteurl="$(awk -F= '/^WP_SITEURL=/{print $2; exit}' .env | tr -d '\r' || true)" + + if [[ -z "$wp_home" ]]; then + add_warning "WP_HOME is not set in .env." + fi + + if [[ -z "$wp_siteurl" ]]; then + add_warning "WP_SITEURL is not set in .env." + fi + + if [[ "$wp_home" == "https://wp.localhost:8443" ]]; then + if [[ ! -f ".certs/wp.localhost.pem" || ! -f ".certs/wp.localhost-key.pem" ]]; then + add_warning "Trusted TLS is configured but cert files are missing. Run: make certs-mkcert" + fi + fi +fi + +check_port_free 8080 "local HTTP (web)" +check_port_free 8443 "local HTTPS (caddy profile)" warning +check_port_free 8081 "phpMyAdmin profile" warning +check_port_free 8025 "MailHog profile" warning + +print_section "Summary" + +if ((${#errors[@]} > 0)); then + printf "Errors:\n" + for message in "${errors[@]}"; do + printf " - %s\n" "$message" + done +fi + +if ((${#warnings[@]} > 0)); then + printf "Warnings:\n" + for message in "${warnings[@]}"; do + printf " - %s\n" "$message" + done +fi + +if ((${#errors[@]} == 0)); then + printf "Doctor checks passed.\n" + if ((${#warnings[@]} > 0)); then + printf "Proceed with caution and review warnings above.\n" + fi + exit 0 +fi + +printf "Doctor checks failed. Fix errors before running bootstrap.\n" +exit 1 + diff --git a/scripts/qa.sh b/scripts/qa.sh new file mode 100755 index 0000000..cda55a0 --- /dev/null +++ b/scripts/qa.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +set -euo pipefail + +echo "== QA: Composer checks ==" +composer validate --strict +composer lint +composer audit + +docker_available=false +if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + docker_available=true +fi + +if [[ "$docker_available" == "false" ]]; then + echo "== QA: Docker checks skipped ==" + echo "Docker CLI/daemon is not available. Skipping doctor/smoke-full." + if [[ "${QA_DOCKER_REQUIRED:-0}" == "1" ]]; then + echo "QA_DOCKER_REQUIRED=1 is set; failing because Docker is unavailable." + exit 1 + fi + exit 0 +fi + +echo "== QA: Docker checks ==" +make smoke-full + diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..1101acf --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if ! command -v docker >/dev/null 2>&1; then + echo "docker is required for smoke checks." + exit 1 +fi + +if ! docker compose ps --services --filter status=running | grep -q '^web$'; then + echo "web service is not running. Start the stack first (for example: make bootstrap)." + exit 1 +fi + +fetch_url() { + local url="$1" + if command -v curl >/dev/null 2>&1; then + curl -fsS "$url" + return + fi + + if command -v wget >/dev/null 2>&1; then + wget -qO- "$url" + return + fi + + echo "Neither curl nor wget is available." + return 1 +} + +echo "Checking /ping endpoint ..." +for _ in $(seq 1 20); do + if fetch_url "http://localhost:8080/ping" >/dev/null 2>&1; then + break + fi + sleep 1 +done + +fetch_url "http://localhost:8080/ping" >/dev/null + +echo "Checking WordPress login page ..." +login_page="$(fetch_url "http://localhost:8080/wp/wp-login.php")" +if ! printf '%s' "$login_page" | grep -Eqi "user_login|wordpress"; then + echo "Unexpected response from wp-login page." + exit 1 +fi + +echo "Checking WP installation state ..." +run_user="$(id -u 2>/dev/null || echo 1000):$(id -g 2>/dev/null || echo 1000)" +docker compose --profile tools run --rm --user "$run_user" wp --path=web/wp core is-installed >/dev/null + +echo "Smoke checks passed." + diff --git a/web/app/cache/.gitkeep b/web/app/cache/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/web/app/cache/.gitkeep @@ -0,0 +1 @@ + diff --git a/web/app/mu-plugins/bedrock-autoloader.php b/web/app/mu-plugins/bedrock-autoloader.php new file mode 100644 index 0000000..0a9e3ed --- /dev/null +++ b/web/app/mu-plugins/bedrock-autoloader.php @@ -0,0 +1,17 @@ + +
+
+

Starter theme placeholder.

+
+ + + + + diff --git a/web/app/themes/starter-theme/functions.php b/web/app/themes/starter-theme/functions.php new file mode 100644 index 0000000..dc57eed --- /dev/null +++ b/web/app/themes/starter-theme/functions.php @@ -0,0 +1,12 @@ + +> + + + + + +> + +
+
+

+

+
+ diff --git a/web/app/themes/starter-theme/index.php b/web/app/themes/starter-theme/index.php new file mode 100644 index 0000000..618e304 --- /dev/null +++ b/web/app/themes/starter-theme/index.php @@ -0,0 +1,24 @@ + + +
+ + +
> +

+
+ +
+
+ + +

No posts found.

+ +
+ +