Skip to content
Merged
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4222cbb
Add reusable deploy file for new projects
itineris-milos May 7, 2026
0e6de19
Feedback
itineris-milos May 7, 2026
3060cbc
Rename file
itineris-milos May 7, 2026
d694413
More feedback
itineris-milos May 7, 2026
2cece4f
Update variable naming
itineris-milos May 7, 2026
14cdece
Add language pack step
itineris-milos May 13, 2026
7eecacc
Change lang input name
itineris-milos May 13, 2026
1e49db5
Language feedback
itineris-milos May 13, 2026
9963754
Testing new language process due to fail deploys
itineris-milos May 13, 2026
4159773
Refactored code
itineris-milos May 13, 2026
cddf905
New attempt
itineris-milos May 13, 2026
e0df550
Attempt xy
itineris-milos May 13, 2026
603aed4
Attempt xxy
itineris-milos May 13, 2026
fdc1f76
chore(workflows): pin deploy-wp action hashes
codepuncher Jun 18, 2026
fc00a24
Bootstrap WordPress core install on first deploy
itineris-milos Jul 1, 2026
10ece8c
Revert "Bootstrap WordPress core install on first deploy"
itineris-milos Jul 1, 2026
8b6d970
Skip WP-dependent deploy steps on never-installed sites
itineris-milos Jul 1, 2026
b1eed14
Address review: keep check-wp-installed generic, explain skips at cal…
itineris-milos Jul 1, 2026
4e5dad2
Skip plugins/themes on wp-cli calls and symlink Wordfence WAF file
itineris-milos Jul 2, 2026
5b4e771
Exclude favicons if not created
itineris-milos Jul 7, 2026
6781961
Copy instead of symlink `wordfence-waf.php`
codepuncher Jul 29, 2026
8d37bd4
Address review: guard uploads symlink, first-deploy rollback, WP_LANG…
itineris-milos Aug 13, 2026
23beeca
Reset OPcache after deploy via itineris-opcache-purge
itineris-milos Aug 13, 2026
96a4861
Use a unique release directory per deploy attempt
itineris-milos Aug 13, 2026
e685eae
Tidy: drop redundant comments, dead step, shorten workflow name
itineris-milos Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 247 additions & 0 deletions .github/workflows/deploy-wp.yml
Comment thread
itineris-milos marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
name: Deploy Site

on:
workflow_call:
inputs:
ENVIRONMENT:
required: true
type: string
THEME_SLUG:
required: true
type: string
PHP_VERSION:
required: true
type: string
SSH_USER:
required: true
type: string
SSH_HOST:
required: true
type: string
WP_LANG:
required: false
type: string
default: en_GB

Comment thread
itineris-milos marked this conversation as resolved.
jobs:
deploy:
runs-on: ubuntu-latest

environment: ${{ inputs.ENVIRONMENT }}

env:
RELEASE_ID: ${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}

concurrency:
group: "${{ github.workflow }}-${{ github.ref_name }}"
cancel-in-progress: false

steps:
- name: Checkout the project
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10

- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version-file: ./wp-content/themes/${{ inputs.THEME_SLUG }}/.nvmrc
registry-url: "https://npm.pkg.github.com/"
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ITINERIS_NPM_AUTH_TOKEN: ${{ secrets.ITINERIS_NPM_AUTH_TOKEN }}

- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240
with:
php-version: ${{ inputs.PHP_VERSION }}
env:
COMPOSER_AUTH_JSON: '{"http-basic": {"repo.packagist.com": {"username": "itineris", "password": "${{ secrets.PRIVATE_PACKAGIST_TOKEN }}"}}}'

- name: Cache Composer packages
id: composer-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
with:
path: vendor
key: ${{ runner.os }}-php-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-php-composer-

- name: Install npm Dependencies [Theme]
working-directory: ./wp-content/themes/${{ inputs.THEME_SLUG }}
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ITINERIS_NPM_AUTH_TOKEN: ${{ secrets.ITINERIS_NPM_AUTH_TOKEN }}

- name: Install Dependencies Composer [WordPress]
run: composer install --no-ansi --no-dev --no-interaction --no-progress --optimize-autoloader --no-scripts --classmap-authoritative

- name: Build Theme
working-directory: ./wp-content/themes/${{ inputs.THEME_SLUG }}
run: npm run build
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ITINERIS_NPM_AUTH_TOKEN: ${{ secrets.ITINERIS_NPM_AUTH_TOKEN }}

- name: Setup SSH
uses: shimataro/ssh-key-action@87a8f067114a8ce263df83e9ed5c849953548bc3
with:
key: ${{ secrets.SSH_KEY }}
known_hosts: ${{ secrets.KNOWN_HOSTS }}

- name: Connect to Tailscale
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8
with:
oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }}
args: "--exit-node=${{ secrets.TAILSCALE_EXIT_NODE }}"
ping: ${{ secrets.TAILSCALE_EXIT_NODE }}
tags: tag:ci

- name: Get current release symlink target
id: getCurrentRelease
run: |
CURRENT_RELEASE_LINK=$(ssh ${{ inputs.SSH_USER }}@${{ inputs.SSH_HOST }} -p ${{ vars.SSH_PORT }} "readlink ~/public || echo ''")
printf 'current_release_link=%s\n' "$CURRENT_RELEASE_LINK" >> "$GITHUB_OUTPUT"
echo "Current release symlink target: $CURRENT_RELEASE_LINK"

- name: Create Release Dir On Remote
run: ssh ${{ inputs.SSH_USER }}@${{ inputs.SSH_HOST }} -p ${{ vars.SSH_PORT }} 'mkdir -p ~/deploy/releases/${{ env.RELEASE_ID }}'

- name: Push files to the remote
run: |
set -euo pipefail
SSH_PORT="${{ vars.SSH_PORT }}"
SSH_DEST="${{ inputs.SSH_USER }}@${{ inputs.SSH_HOST }}"
REMOTE_BASE="~/deploy/releases/${{ env.RELEASE_ID }}"
RSYNC_COMMON_OPTS="-avz --exclude-from=.deployignore"
SSH_CMD="ssh -p ${SSH_PORT}"

rsync ${RSYNC_COMMON_OPTS} -e "${SSH_CMD}" ./wp/ "${SSH_DEST}:${REMOTE_BASE}"
rsync ${RSYNC_COMMON_OPTS} -e "${SSH_CMD}" ./wp-config.php "${SSH_DEST}:${REMOTE_BASE}"
rsync ${RSYNC_COMMON_OPTS} -e "${SSH_CMD}" ./config "${SSH_DEST}:${REMOTE_BASE}"
rsync ${RSYNC_COMMON_OPTS} -e "${SSH_CMD}" ./vendor "${SSH_DEST}:${REMOTE_BASE}"
rsync ${RSYNC_COMMON_OPTS} -e "${SSH_CMD}" ./wp-content "${SSH_DEST}:${REMOTE_BASE}"

if [[ -d ./assets ]]; then
rsync ${RSYNC_COMMON_OPTS} -e "${SSH_CMD}" ./assets "${SSH_DEST}:${REMOTE_BASE}"
fi

if [[ -f ./favicon.ico ]]; then
rsync ${RSYNC_COMMON_OPTS} -e "${SSH_CMD}" ./favicon.ico "${SSH_DEST}:${REMOTE_BASE}"
fi

- name: Create the shared directory if it doesn't exist
run: ssh ${{ inputs.SSH_USER }}@${{ inputs.SSH_HOST }} -p ${{ vars.SSH_PORT }} 'mkdir -p ~/deploy/shared'

- name: Create the uploads directory if it doesn't exist
run: ssh ${{ inputs.SSH_USER }}@${{ inputs.SSH_HOST }} -p ${{ vars.SSH_PORT }} 'mkdir -p ~/deploy/shared/uploads'

- name: Symlink uploads directory
run: ssh ${{ inputs.SSH_USER }}@${{ inputs.SSH_HOST }} -p ${{ vars.SSH_PORT }} 'rm -rf ~/deploy/releases/${{ env.RELEASE_ID }}/wp-content/uploads && ln -sfn ~/deploy/shared/uploads ~/deploy/releases/${{ env.RELEASE_ID }}/wp-content/uploads'

- name: Symlink .env file
run: ssh ${{ inputs.SSH_USER }}@${{ inputs.SSH_HOST }} -p ${{ vars.SSH_PORT }} 'touch ~/deploy/shared/.env && ln -sfn ~/deploy/shared/.env ~/deploy/releases/${{ env.RELEASE_ID }}/.env'

- name: Copy Wordfence WAF file
run: ssh ${{ inputs.SSH_USER }}@${{ inputs.SSH_HOST }} -p ${{ vars.SSH_PORT }} 'if [ -f ~/deploy/shared/wordfence-waf.php ]; then cp ~/deploy/shared/wordfence-waf.php ~/deploy/releases/${{ env.RELEASE_ID }}/wordfence-waf.php; else echo "No shared wordfence-waf.php yet - skipping."; fi'

- name: Check if WordPress is already installed
id: check-wp-installed
run: |
if ssh ${{ inputs.SSH_USER }}@${{ inputs.SSH_HOST }} -p ${{ vars.SSH_PORT }} 'cd ~/deploy/releases/${{ env.RELEASE_ID }} && wp core is-installed'; then
echo "installed=true" >> "$GITHUB_OUTPUT"
else
echo "installed=false" >> "$GITHUB_OUTPUT"
echo "::warning::WordPress isn't installed on this site yet."
fi

- name: Install language packs
if: steps.check-wp-installed.outputs.installed == 'true'
run: |
ssh ${{ inputs.SSH_USER }}@${{ inputs.SSH_HOST }} -p ${{ vars.SSH_PORT }} 'cd ~/deploy/releases/${{ env.RELEASE_ID }} && \
rm -rf wp-content/languages && \
wp --skip-plugins --skip-themes language core install "${{ inputs.WP_LANG }}" && \
wp --skip-plugins --skip-themes option set WPLANG "${{ inputs.WP_LANG }}"'

- name: Promote release symlink
run: ssh ${{ inputs.SSH_USER }}@${{ inputs.SSH_HOST }} -p ${{ vars.SSH_PORT }} 'ln -sfn ~/deploy/releases/${{ env.RELEASE_ID }} ~/public'

- name: Check if basic auth is configured
id: check-basic-auth
run: |
if [ -n "${{ secrets.BASIC_AUTH_PASSWORD }}" ]; then
echo "has-basic-auth=true" >> "$GITHUB_OUTPUT"
else
echo "has-basic-auth=false" >> "$GITHUB_OUTPUT"
fi

- name: Test the new release with smoke test (with basic auth)
if: steps.check-basic-auth.outputs.has-basic-auth == 'true'
uses: jtalk/url-health-check-action@b716ccb6645355dd9fcce8002ce460e5474f7f00
with:
url: ${{ vars.SITE_URL }}?nocache
max-attempts: 3
retry-delay: 10s
basic-auth: "${{ secrets.BASIC_AUTH_USERNAME }}:${{ secrets.BASIC_AUTH_PASSWORD }}"

- name: Test the new release with smoke test (without basic auth)
if: steps.check-basic-auth.outputs.has-basic-auth == 'false'
uses: jtalk/url-health-check-action@b716ccb6645355dd9fcce8002ce460e5474f7f00
with:
url: ${{ vars.SITE_URL }}?nocache
max-attempts: 3
retry-delay: 10s

- name: Rollback deployment on failure
if: failure()
run: |
ssh ${{ inputs.SSH_USER }}@${{ inputs.SSH_HOST }} -p ${{ vars.SSH_PORT }} '
PREVIOUS_RELEASE="${{ steps.getCurrentRelease.outputs.current_release_link }}"
if [ -n "$PREVIOUS_RELEASE" ]; then
ln -sfn "$PREVIOUS_RELEASE" ~/public
else
echo "No previous release to roll back to."
fi
rm -rf ~/deploy/releases/${{ env.RELEASE_ID }}
'

- name: Reset OPcache
if: success()
env:
OPCACHE_PURGE_TOKEN: ${{ secrets.OPCACHE_PURGE_TOKEN }}
OPCACHE_PURGE_USER_AGENT: ${{ secrets.OPCACHE_PURGE_USER_AGENT }}
SITE_URL: ${{ vars.SITE_URL }}
BASIC_AUTH_USERNAME: ${{ secrets.BASIC_AUTH_USERNAME }}
BASIC_AUTH_PASSWORD: ${{ secrets.BASIC_AUTH_PASSWORD }}
run: |
if [ -z "${OPCACHE_PURGE_TOKEN}" ]; then
echo 'OPCACHE_PURGE_TOKEN not configured - skipping OPcache reset.'
exit 0
fi
CURL_AUTH=()
if [ -n "${BASIC_AUTH_PASSWORD}" ]; then
CURL_AUTH=(--user "${BASIC_AUTH_USERNAME}:${BASIC_AUTH_PASSWORD}")
fi
BODY="$(curl --silent --max-time 30 \
"${CURL_AUTH[@]}" \
--user-agent "${OPCACHE_PURGE_USER_AGENT:-ItinerisServer/1.0}" \
--header "X-Opcache-Purge: ${OPCACHE_PURGE_TOKEN}" \
"${SITE_URL}?opcache_purge=deploy-${GITHUB_SHA}" || true)"
if printf '%s' "${BODY}" | grep -q '"reset":true'; then
echo "OPcache reset: OK"
else
echo "::warning::OPcache purge not confirmed (mu-plugin missing, token mismatch, or edge block). Deploy unaffected."
fi

- name: Clear Kinsta site cache if deployment does not fail
if: success() && steps.check-wp-installed.outputs.installed == 'true'
run: ssh ${{ inputs.SSH_USER }}@${{ inputs.SSH_HOST }} -p ${{ vars.SSH_PORT }} 'cd ~/public && wp --skip-plugins --skip-themes kinsta cache purge --site'

- name: Install plugin and theme language packs
if: steps.check-wp-installed.outputs.installed == 'true'
run: |
ssh ${{ inputs.SSH_USER }}@${{ inputs.SSH_HOST }} -p ${{ vars.SSH_PORT }} 'cd ~/public && \
wp --skip-plugins --skip-themes language plugin install --all "${{ inputs.WP_LANG }}" && \
wp --skip-plugins --skip-themes language theme install --all "${{ inputs.WP_LANG }}"'

- name: Cleanup old deployments
run: ssh ${{ inputs.SSH_USER }}@${{ inputs.SSH_HOST }} -p ${{ vars.SSH_PORT }} "cd ~/deploy/releases && ls -t | tail -n +4 | xargs -r rm -rf"