Skip to content

Build and deployment

How code gets from a branch to WP Engine: Bitbucket Pipelines running git ftp push over SFTP, with compiled assets committed to the repository and no build or test step in CI.

WP Engine’s standard deployment is a git push to their own remote, but this project deploys by SFTP from Bitbucket instead, using StrategiQ’s shared deployomatic Docker image. Because there is no build step on the server or in the pipeline, the compiled CSS and JS have to be committed.

PathWhat it does
bitbucket-pipelines.ymlThe whole deployment definition
.gitignoreDecides what is even in the repo — WordPress core and most plugins are excluded
wp-content/themes/supplychainschool/gulpfile.jsMain theme build (Laravel Elixir / Gulp 3)
wp-content/themes/carboncalculator/gulpfile.jsCarbon theme build (Gulp + Tailwind + PostCSS)
wp-content/themes/supplychainschool/functions.php:126Cache busting keyed off .git-ftp.log

Image: strategiq/deployomatic:curl.

TriggerTarget
Push to development$DEVELOPMENT_SFTP_*
Push to staging$STAGING_SFTP_*
Manual deploy-production pipeline$PRODUCTION_SFTP_*

Each step is two commands: git checkout $BITBUCKET_COMMIT, then git ftp push --insecure --auto-init --user … --passwd … --remote-root … <host>.

Production is manual only — there is no branch pipeline for it, so production deploys are a deliberate button press in the Bitbucket UI. master has no pipeline at all.

git-ftp records what it uploaded in a .git-ftp.log file on the server containing the deployed commit hash, and --auto-init creates that file on a first deploy.

enqueue_scripts() (themes/supplychainschool/functions.php:126) picks the asset query string three ways:

ConditionQuery string
$_SERVER['IS_WPE'] not set (local/other host)?v=<time()> — never cached
On WP Engine and .git-ftp.log exists?v=<deployed commit hash>
On WP Engine, no .git-ftp.lognone

So the deployed commit hash is the cache key. get_cache_key() (functions.php:621) exposes the same logic. Note file_exists('.git-ftp.log') is a relative path, so it resolves against the PHP working directory rather than the site root.

.gitignore excludes WordPress core files individually, wp-config.php, wp-content/uploads/, all logs (*.log), and — importantly — wp-content/plugins/* and wp-content/mu-plugins/*, with a single exception:

!wp-content/plugins/carboncalc

So the only plugin under version control is the custom carboncalc plugin. The 27 third-party plugins present locally (ACF Pro, CF7, Wordfence, Yoast, Autoptimize, Redirection, Smush, WP Crontrol, Sucuri, Google Apps Login, Juicer, …) are not deployed by the pipeline and are managed directly on each environment.

Both themes are tracked, including their node_modules absence and their committed assets/ output.

Local by Flywheel. The local host is supplychainschool.build, with carbon.supplychainschool.build for the Carbon subsite — these hostnames are in the staging allow-list in LMSClient.php:5, which is what makes local point at staging Moodle. local-adminer-*.php and local-xdebuginfo.php are Local’s own helpers and are gitignored.

Long-lived development and staging branches, master as the main branch, and a large number of feature/*, hotfix/* and per-developer development-<name> branches. Deployment is per branch, not per tag.

Bitbucket repository variables (secured, never in the repo) — the file’s own header documents them:

DEVELOPMENT_SFTP_USER, DEVELOPMENT_SFTP_PASS, DEVELOPMENT_SFTP_HOST, DEVELOPMENT_SFTP_ROOT, and the same four with STAGING_ and PRODUCTION_ prefixes.

Environment detection at runtime: $_SERVER['IS_WPE'] (set by WP Engine), WP_ENVIRONMENT_TYPE in wp-config.php, and the hostname allow-list in LMSClient.php.

  • There is no build step in CI. git ftp push uploads the repository as it is. Forgetting npm run prod before committing means SCSS and JS changes never reach the server, and nothing warns you.
  • There are no tests in CI, and no linting — phpcs.xml.dist exists in the main theme but is not run.
  • Third-party plugins are not deployed. Updating a plugin means doing it in each environment’s wp-admin. Environments can therefore drift, and a plugin present locally may be absent on staging.
  • --insecure skips SFTP host key verification.
  • git ftp push deploys the diff between the last deployed commit and now, as recorded in .git-ftp.log on the server. If that file is deleted or wrong, the next deploy either re-uploads everything or skips files. Do not hand-edit it.
  • Deleting .git-ftp.log silently turns off cache busting on WP Engine (functions.php:131) — assets are served with no query string at all.
  • Production deploys from whatever commit the manual pipeline is run against, which need not be on master.
  • The Carbon theme uses Tailwind, whose content scan covers **/*.php and resources/**/*.{js,jsx} only. A class that appears solely in a JS template literal under assets/js/ is purged from the build.
  • The main theme’s toolchain (Laravel Elixir, Gulp 3, node >= 10.9) will not run on a current Node without workarounds. npm-shrinkwrap.json is committed and should be respected.
  • WP Engine page caching is in play (plus Autoptimize and wpe-advanced-cache-options), which is why logged-in header state is a client-side swap — see [[site-chrome-and-navigation]].
  • Adding a plugin to version control means adding a !wp-content/plugins/<dir> negation to .gitignore after the wp-content/plugins/* line. Order matters in gitignore.
  • Adding a CI step: both branch pipelines are two-line scripts. A npm ci && npm run prod step per theme before the push would remove the forgot-to-build failure mode, but the pipeline image must then have a compatible Node — check deployomatic:curl first.
  • Never commit wp-config.php. Constants belong there (MOODLE_WS_TOKEN, COMPANIES_HOUSE_API_KEY, the Carbon flags); the repo should only ever reference them by name.
  • Verify a deploy: check the commit hash in .git-ftp.log on the server matches what you pushed, then view source on a page and confirm the asset query string is that hash.
  • Deliberately not abstracted: SFTP rather than WP Engine’s git push. Changing it would change the cache-busting mechanism too, since that reads .git-ftp.log.

None, in CI or locally.

Related: [[acf-flexible-content-blocks]], [[site-chrome-and-navigation]], [[company-emissions-dashboard]].