From e9a05713f0d47381ec863ffe291165f4cd23121f Mon Sep 17 00:00:00 2001 From: Will Haynes Date: Sat, 18 Jul 2026 16:39:02 -0500 Subject: [PATCH 1/2] adopt publisher, docker-wordpress --- .dockerignore | 14 +++ .github/actions/publisher/action.yml | 102 ++++++++++++++++++ .../actions/publisher/scripts/plan-tags.sh | 66 ++++++++++++ .github/workflows/deploy.yml | 81 ++++++++++++-- Dockerfile | 28 ++--- config/wordpress/entrypoint.sh | 20 ++++ dev.env | 9 +- docker-compose.yml | 11 +- readme.md | 7 +- 9 files changed, 306 insertions(+), 32 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/actions/publisher/action.yml create mode 100755 .github/actions/publisher/scripts/plan-tags.sh create mode 100755 config/wordpress/entrypoint.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..793573a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.DS_Store +.env +*.env +!.env.example + +.git +.github +.stencil +.vagrant + +bin +node_modules +server +www diff --git a/.github/actions/publisher/action.yml b/.github/actions/publisher/action.yml new file mode 100644 index 0000000..53abd43 --- /dev/null +++ b/.github/actions/publisher/action.yml @@ -0,0 +1,102 @@ +name: Publisher +description: Build, publish, and delete GHCR container image tags. + +inputs: + github-token: + description: Token used for GHCR login and tag deletion. + required: true + image: + description: Full GHCR image name without a tag, for example ghcr.io/broadsheet-technology/my-service. + required: true + tags: + description: Optional newline- or comma-separated raw tag names to publish. + required: false + default: "" + delete-tags: + description: Optional newline- or comma-separated tags to delete from GHCR. + required: false + default: "" + +runs: + using: composite + steps: + - name: Plan image tags + id: plan + shell: bash + env: + IMAGE: ${{ inputs.image }} + TAGS: ${{ inputs.tags }} + DELETE_TAGS: ${{ inputs.delete-tags }} + run: ${{ github.action_path }}/scripts/plan-tags.sh + + - name: Set up QEMU + if: steps.plan.outputs.publish == 'true' + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - name: Set up Docker Buildx + if: steps.plan.outputs.publish == 'true' + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: steps.plan.outputs.publish == 'true' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ inputs.github-token }} + + - name: Build and publish image + if: steps.plan.outputs.publish == 'true' + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: true + platforms: linux/arm64 + tags: ${{ steps.plan.outputs.tags }} + + - name: Delete GHCR tags + if: steps.plan.outputs.delete == 'true' + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + IMAGE: ${{ inputs.image }} + DELETE_TAGS: ${{ steps.plan.outputs.delete_tags }} + run: | + set -euo pipefail + + if [[ "$IMAGE" != ghcr.io/* ]]; then + echo "::error::delete-tags currently supports GHCR images only." + exit 1 + fi + + image_path="${IMAGE#ghcr.io/}" + owner="${image_path%%/*}" + package="${image_path#*/}" + encoded_package="$(jq -rn --arg value "$package" '$value|@uri')" + + org_versions_path="/orgs/$owner/packages/container/$encoded_package/versions" + user_versions_path="/users/$owner/packages/container/$encoded_package/versions" + + while IFS= read -r tag; do + [ -z "$tag" ] && continue + + ids="$( + gh api "$org_versions_path" --paginate --jq ".[] | select(.metadata.container.tags[]? == \"$tag\") | .id" 2>/dev/null \ + || gh api "$user_versions_path" --paginate --jq ".[] | select(.metadata.container.tags[]? == \"$tag\") | .id" 2>/dev/null \ + || true + )" + if [ -z "$ids" ]; then + echo "::notice::No GHCR package version found for $IMAGE:$tag" + continue + fi + + while IFS= read -r id; do + [ -z "$id" ] && continue + gh api --method DELETE "$org_versions_path/$id" 2>/dev/null \ + || gh api --method DELETE "$user_versions_path/$id" + echo "::notice::Deleted GHCR package version $id for $IMAGE:$tag" + done <<< "$ids" + done <<< "$DELETE_TAGS" diff --git a/.github/actions/publisher/scripts/plan-tags.sh b/.github/actions/publisher/scripts/plan-tags.sh new file mode 100755 index 0000000..e318560 --- /dev/null +++ b/.github/actions/publisher/scripts/plan-tags.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${IMAGE:?IMAGE is required}" +: "${TAGS:=}" +: "${DELETE_TAGS:=}" + +normalize_tags() { + local raw="$1" + + printf "%s\n" "$raw" \ + | tr "," "\n" \ + | sed -e "s/\r$//" -e "s/^[[:space:]]*//" -e "s/[[:space:]]*$//" \ + | awk "NF && !seen[\$0]++" +} + +validate_raw_tag() { + local tag="$1" + + if [[ "$tag" == *":"* || "$tag" == */* ]]; then + echo "::error::Expected a raw tag name, got '$tag'. Do not include the image name." + exit 1 + fi + + if [[ ! "$tag" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then + echo "::error::Invalid Docker tag '$tag'." + exit 1 + fi +} + +mapfile -t publish_tags < <(normalize_tags "$TAGS") +mapfile -t delete_tags < <(normalize_tags "$DELETE_TAGS") + +for tag in "${publish_tags[@]}"; do + validate_raw_tag "$tag" +done + +for tag in "${delete_tags[@]}"; do + validate_raw_tag "$tag" +done + +{ + if [ "${#publish_tags[@]}" -gt 0 ]; then + echo "publish=true" + else + echo "publish=false" + fi + + if [ "${#delete_tags[@]}" -gt 0 ]; then + echo "delete=true" + else + echo "delete=false" + fi + + echo "tags<> "$GITHUB_OUTPUT" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b0d2f0c..0ea19bc 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,26 +1,95 @@ name: Build & Deploy + on: push: branches: [master] + workflow_dispatch: + inputs: + tags: + description: Optional newline- or comma-separated image tags to publish. + required: false + default: "" + delete-tags: + description: Optional newline- or comma-separated image tags to delete. + required: false + default: "" + +permissions: + contents: read + packages: write jobs: + publish: + runs-on: ubuntu-latest + outputs: + image: ${{ steps.image.outputs.image }} + deploy-tag: ${{ steps.publisher-inputs.outputs.deploy-tag }} + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set image name + id: image + shell: bash + run: echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + + - name: Select publisher inputs + id: publisher-inputs + shell: bash + env: + INPUT_TAGS: ${{ github.event.inputs.tags }} + run: | + if [ "${GITHUB_EVENT_NAME}" = "push" ]; then + { + echo "tags<> "$GITHUB_OUTPUT" + else + { + echo "tags<> /tmp/cron.log"; } | crontab - +COPY --from=stencil --chown=www-data:www-data /app/bin/wp-content/themes/wjh.dev /opt/wjh.dev/themes/wjh.dev +COPY --from=stencil --chown=www-data:www-data /app/bin/stencil-stats.json /opt/wjh.dev/stencil-stats.json +COPY config/wordpress/entrypoint.sh /usr/local/bin/wjhdev-entrypoint -# 4/ symolically link wordpress theme -COPY --from=stencil /app/bin/wp-content/themes /app/bin/themes -RUN touch /var/www/html/wp-content/themes; ln -s /app/bin/themes /var/www/html/wp-content/themes +RUN chmod +x /usr/local/bin/wjhdev-entrypoint -# 5/ dump env vars (for cron process), start cron service & run wordpress's docker-entrypoint -CMD printenv > /etc/environment && service cron start && bash /usr/local/bin/docker-entrypoint.sh php-fpm \ No newline at end of file +ENTRYPOINT ["/usr/local/bin/wjhdev-entrypoint"] diff --git a/config/wordpress/entrypoint.sh b/config/wordpress/entrypoint.sh new file mode 100755 index 0000000..42a574e --- /dev/null +++ b/config/wordpress/entrypoint.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +theme_source="/opt/wjh.dev/themes/wjh.dev" +theme_target="/var/www/html/wp-content/themes/wjh.dev" +stats_source="/opt/wjh.dev/stencil-stats.json" +stats_target="/srv/stencil-stats.json" + +mkdir -p "$(dirname "$theme_target")" "$(dirname "$stats_target")" + +rm -rf "$theme_target" +cp -a "$theme_source" "$theme_target" +cp "$stats_source" "$stats_target" +chown -R www-data:www-data "$theme_target" "$stats_target" + +if command -v docker-entrypoint.sh >/dev/null 2>&1; then + exec docker-entrypoint.sh "$@" +fi + +exec "$@" diff --git a/dev.env b/dev.env index 046cbd0..deb27cd 100644 --- a/dev.env +++ b/dev.env @@ -6,6 +6,13 @@ SITE_URL=wjh.dev.test SITE_WP_CONTENT_DIR=/bin/wp-content # WORDPRESS: +WORDPRESS_IMAGE=ghcr.io/wjhdev/wjh.dev:latest +BT_PHP_PM=dynamic +BT_PHP_PM_MAX_CHILDREN=30 +BT_PHP_PM_MAX_REQUESTS=500 +BT_PHP_UPLOAD_MAX_FILESIZE=6M +BT_PHP_OPCACHE_MAX_ACCELERATED_FILES=20000 +BT_PHP_OPCACHE_MEMORY_CONSUMPTION=256 WORDPRESS_DB_HOST=mariadb:3306 WORDPRESS_DB_NAME=wordpress WORDPRESS_DB_USER=wordpress @@ -16,4 +23,4 @@ WORDPRESS_UPLOADS_DIR=/bin/wp-content/uploads REDIS_PASSWORD=12345 # NEW RELIC -NRIA_LICENSE_KEY= \ No newline at end of file +NRIA_LICENSE_KEY= diff --git a/docker-compose.yml b/docker-compose.yml index 03195de..48b38b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,6 @@ services: - 443:443 volumes: - wjh.dev-wordpress:/var/www/html - - ./${SITE_WP_CONTENT_DIR}/themes:/var/www/html/wp-content/themes - ./${SITE_WP_CONTENT_DIR}/uploads:/var/www/html/wp-content/uploads restart: always logging: @@ -23,7 +22,7 @@ services: DOMAIN: ${SITE_URL} wordpress: - image: ghcr.io/broadsheet-technology/wordpress:0.4 + image: ${WORDPRESS_IMAGE:-ghcr.io/wjhdev/wjh.dev:latest} container_name: ${COMPOSE_PROJECT_NAME}-wordpress links: - redis @@ -36,12 +35,16 @@ services: max-file: 10 volumes: - wjh.dev-wordpress:/var/www/html - - ./${SITE_WP_CONTENT_DIR}/themes:/var/www/html/wp-content/themes - ./${SITE_WP_CONTENT_DIR}/uploads:/var/www/html/wp-content/uploads - - ./bin/stencil-stats.json:/srv/stencil-stats.json environment: NRIA_LICENSE_KEY: ${NRIA_LICENSE_KEY} NRIA_APP_NAME: ${SITE_URL}-wordpress\/app + BT_PHP_PM: ${BT_PHP_PM:-dynamic} + BT_PHP_PM_MAX_CHILDREN: ${BT_PHP_PM_MAX_CHILDREN:-30} + BT_PHP_PM_MAX_REQUESTS: ${BT_PHP_PM_MAX_REQUESTS:-500} + BT_PHP_UPLOAD_MAX_FILESIZE: ${BT_PHP_UPLOAD_MAX_FILESIZE:-6M} + BT_PHP_OPCACHE_MAX_ACCELERATED_FILES: ${BT_PHP_OPCACHE_MAX_ACCELERATED_FILES:-20000} + BT_PHP_OPCACHE_MEMORY_CONSUMPTION: ${BT_PHP_OPCACHE_MEMORY_CONSUMPTION:-256} WORDPRESS_DB_HOST: ${WORDPRESS_DB_HOST} WORDPRESS_DB_USER: ${WORDPRESS_DB_USER} WORDPRESS_DB_PASSWORD: ${WORDPRESS_DB_PASSWORD} diff --git a/readme.md b/readme.md index 963aeac..df95b86 100644 --- a/readme.md +++ b/readme.md @@ -16,8 +16,9 @@ vagrant up #### Deploying -To **deploy** install docker & docker compose. Then run: +To **deploy** install docker & docker compose. The GitHub Actions workflow publishes +`ghcr.io/wjhdev/wjh.dev` and then starts compose with that image: ``` -docker-compose up -d -``` \ No newline at end of file +WORDPRESS_IMAGE=ghcr.io/wjhdev/wjh.dev:latest docker-compose up -d +``` From b8f9d78faa7d1add2c64e112258ec02e33e3b841 Mon Sep 17 00:00:00 2001 From: Will Haynes Date: Sat, 18 Jul 2026 17:55:16 -0500 Subject: [PATCH 2/2] adopt publisher; add staging --- .github/actions/publisher/action.yml | 102 ------------------ .../actions/publisher/scripts/plan-tags.sh | 66 ------------ .github/workflows/deploy.yml | 65 +++-------- .github/workflows/stage-image.yml | 32 ++++++ readme.md | 4 +- 5 files changed, 51 insertions(+), 218 deletions(-) delete mode 100644 .github/actions/publisher/action.yml delete mode 100755 .github/actions/publisher/scripts/plan-tags.sh create mode 100644 .github/workflows/stage-image.yml diff --git a/.github/actions/publisher/action.yml b/.github/actions/publisher/action.yml deleted file mode 100644 index 53abd43..0000000 --- a/.github/actions/publisher/action.yml +++ /dev/null @@ -1,102 +0,0 @@ -name: Publisher -description: Build, publish, and delete GHCR container image tags. - -inputs: - github-token: - description: Token used for GHCR login and tag deletion. - required: true - image: - description: Full GHCR image name without a tag, for example ghcr.io/broadsheet-technology/my-service. - required: true - tags: - description: Optional newline- or comma-separated raw tag names to publish. - required: false - default: "" - delete-tags: - description: Optional newline- or comma-separated tags to delete from GHCR. - required: false - default: "" - -runs: - using: composite - steps: - - name: Plan image tags - id: plan - shell: bash - env: - IMAGE: ${{ inputs.image }} - TAGS: ${{ inputs.tags }} - DELETE_TAGS: ${{ inputs.delete-tags }} - run: ${{ github.action_path }}/scripts/plan-tags.sh - - - name: Set up QEMU - if: steps.plan.outputs.publish == 'true' - uses: docker/setup-qemu-action@v3 - with: - platforms: arm64 - - - name: Set up Docker Buildx - if: steps.plan.outputs.publish == 'true' - uses: docker/setup-buildx-action@v3 - - - name: Log in to GHCR - if: steps.plan.outputs.publish == 'true' - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ inputs.github-token }} - - - name: Build and publish image - if: steps.plan.outputs.publish == 'true' - uses: docker/build-push-action@v6 - with: - context: . - file: Dockerfile - push: true - platforms: linux/arm64 - tags: ${{ steps.plan.outputs.tags }} - - - name: Delete GHCR tags - if: steps.plan.outputs.delete == 'true' - shell: bash - env: - GH_TOKEN: ${{ inputs.github-token }} - IMAGE: ${{ inputs.image }} - DELETE_TAGS: ${{ steps.plan.outputs.delete_tags }} - run: | - set -euo pipefail - - if [[ "$IMAGE" != ghcr.io/* ]]; then - echo "::error::delete-tags currently supports GHCR images only." - exit 1 - fi - - image_path="${IMAGE#ghcr.io/}" - owner="${image_path%%/*}" - package="${image_path#*/}" - encoded_package="$(jq -rn --arg value "$package" '$value|@uri')" - - org_versions_path="/orgs/$owner/packages/container/$encoded_package/versions" - user_versions_path="/users/$owner/packages/container/$encoded_package/versions" - - while IFS= read -r tag; do - [ -z "$tag" ] && continue - - ids="$( - gh api "$org_versions_path" --paginate --jq ".[] | select(.metadata.container.tags[]? == \"$tag\") | .id" 2>/dev/null \ - || gh api "$user_versions_path" --paginate --jq ".[] | select(.metadata.container.tags[]? == \"$tag\") | .id" 2>/dev/null \ - || true - )" - if [ -z "$ids" ]; then - echo "::notice::No GHCR package version found for $IMAGE:$tag" - continue - fi - - while IFS= read -r id; do - [ -z "$id" ] && continue - gh api --method DELETE "$org_versions_path/$id" 2>/dev/null \ - || gh api --method DELETE "$user_versions_path/$id" - echo "::notice::Deleted GHCR package version $id for $IMAGE:$tag" - done <<< "$ids" - done <<< "$DELETE_TAGS" diff --git a/.github/actions/publisher/scripts/plan-tags.sh b/.github/actions/publisher/scripts/plan-tags.sh deleted file mode 100755 index e318560..0000000 --- a/.github/actions/publisher/scripts/plan-tags.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${IMAGE:?IMAGE is required}" -: "${TAGS:=}" -: "${DELETE_TAGS:=}" - -normalize_tags() { - local raw="$1" - - printf "%s\n" "$raw" \ - | tr "," "\n" \ - | sed -e "s/\r$//" -e "s/^[[:space:]]*//" -e "s/[[:space:]]*$//" \ - | awk "NF && !seen[\$0]++" -} - -validate_raw_tag() { - local tag="$1" - - if [[ "$tag" == *":"* || "$tag" == */* ]]; then - echo "::error::Expected a raw tag name, got '$tag'. Do not include the image name." - exit 1 - fi - - if [[ ! "$tag" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then - echo "::error::Invalid Docker tag '$tag'." - exit 1 - fi -} - -mapfile -t publish_tags < <(normalize_tags "$TAGS") -mapfile -t delete_tags < <(normalize_tags "$DELETE_TAGS") - -for tag in "${publish_tags[@]}"; do - validate_raw_tag "$tag" -done - -for tag in "${delete_tags[@]}"; do - validate_raw_tag "$tag" -done - -{ - if [ "${#publish_tags[@]}" -gt 0 ]; then - echo "publish=true" - else - echo "publish=false" - fi - - if [ "${#delete_tags[@]}" -gt 0 ]; then - echo "delete=true" - else - echo "delete=false" - fi - - echo "tags<> "$GITHUB_OUTPUT" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0ea19bc..b5a7c70 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,72 +1,39 @@ -name: Build & Deploy +name: Publish & Deploy on: push: branches: [master] workflow_dispatch: - inputs: - tags: - description: Optional newline- or comma-separated image tags to publish. - required: false - default: "" - delete-tags: - description: Optional newline- or comma-separated image tags to delete. - required: false - default: "" permissions: contents: read packages: write jobs: - publish: + image: runs-on: ubuntu-latest - outputs: - image: ${{ steps.image.outputs.image }} - deploy-tag: ${{ steps.publisher-inputs.outputs.deploy-tag }} steps: - name: Check out repository uses: actions/checkout@v4 - - name: Set image name - id: image + - name: Read package version + id: package shell: bash - run: echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" - - - name: Select publisher inputs - id: publisher-inputs - shell: bash - env: - INPUT_TAGS: ${{ github.event.inputs.tags }} run: | - if [ "${GITHUB_EVENT_NAME}" = "push" ]; then - { - echo "tags<> "$GITHUB_OUTPUT" - else - { - echo "tags<> "$GITHUB_OUTPUT" - name: Publish image - uses: ./.github/actions/publisher + uses: broadsheet-technology/publisher@v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} - image: ${{ steps.image.outputs.image }} - tags: ${{ steps.publisher-inputs.outputs.tags }} - delete-tags: ${{ github.event.inputs.delete-tags }} + image: ghcr.io/${{ github.repository }} + tags: | + ${{ github.sha }} + ${{ steps.package.outputs.version }} + latest deploy: - needs: publish + needs: image if: github.event_name == 'push' runs-on: ubuntu-latest environment: production @@ -74,7 +41,7 @@ jobs: - name: Deploy uses: appleboy/ssh-action@v0.1.2 env: - WORDPRESS_IMAGE: ${{ needs.publish.outputs.image }}:${{ needs.publish.outputs.deploy-tag }} + WORDPRESS_IMAGE: ghcr.io/${{ github.repository }}:${{ github.sha }} GHCR_USERNAME: ${{ github.actor }} GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -86,9 +53,9 @@ jobs: script: | set -eu cd ~/wjh.dev - git fetch origin master - git checkout -f master - git reset --hard origin/master + git fetch origin main + git checkout -B main origin/main + git reset --hard origin/main echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin docker-compose pull wordpress docker-compose up -d --build diff --git a/.github/workflows/stage-image.yml b/.github/workflows/stage-image.yml new file mode 100644 index 0000000..3c2d273 --- /dev/null +++ b/.github/workflows/stage-image.yml @@ -0,0 +1,32 @@ +name: Stage Image + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled, closed] + +permissions: + contents: read + packages: write + pull-requests: read + +jobs: + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Publish staging image + if: github.event.action != 'closed' && contains(github.event.pull_request.labels.*.name, 'stage') + uses: broadsheet-technology/publisher@v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + image: ghcr.io/${{ github.repository }} + tags: stage-pr-${{ github.event.pull_request.number }} + + - name: Delete staging image + if: github.event.action == 'closed' || !contains(github.event.pull_request.labels.*.name, 'stage') + uses: broadsheet-technology/publisher@v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + image: ghcr.io/${{ github.repository }} + delete-tags: stage-pr-${{ github.event.pull_request.number }} diff --git a/readme.md b/readme.md index df95b86..24f94b8 100644 --- a/readme.md +++ b/readme.md @@ -17,8 +17,10 @@ vagrant up #### Deploying To **deploy** install docker & docker compose. The GitHub Actions workflow publishes -`ghcr.io/wjhdev/wjh.dev` and then starts compose with that image: +`ghcr.io/wjhdev/wjh.dev` from `main` and then starts compose with that image: ``` WORDPRESS_IMAGE=ghcr.io/wjhdev/wjh.dev:latest docker-compose up -d ``` + +Pull requests labeled `stage` publish `ghcr.io/wjhdev/wjh.dev:stage-pr-`.