BLT Is EOL: Moving to Acquia CLI and Azure Pipelines

For years, Acquia BLT was the glue in our Drupal platform. It set up local environments, ran the build in CI, produced the deploy artifact and pushed it to Acquia. Then Acquia announced its end of life, together with a fairly direct message: you don't need BLT on Acquia Cloud.

That turned out to be true. We removed BLT in the same release that moved the platform to Drupal 10.2 and PHP 8.2. Its job is now done by Drush, Acquia CLI and a handful of shell scripts in Azure Pipelines. The pipeline went from 23 minutes to between 5 and 9. I published the setup as acli-azure-pipelines. This post explains why each piece is there.

What BLT was actually doing

Before deleting anything, I wrote down what BLT did for us in practice. Most of its features we never used. The ones we relied on were:

Each of those maps to one tool that already existed: Drush handles the install, acli push:artifact handles the build and the tag, and acli pull covers local work. BLT's own layer of commands and configuration on top could simply go.

The pipeline

This is the whole pipeline. Every step has one job, and the logic lives in small scripts that you can also run by hand.

pool:
  vmImage: ubuntu-latest

variables:
  phpVersion: 8.2
  scriptDir: ./scripts

steps:
  - task: InstallSSHKey@0
    displayName: Install SSH key for GitHub
    inputs:
      knownHostsEntry: $(github_known_hosts)
      sshPublicKey: $(github_public_key)
      sshKeySecureFile: github_secret_key

  - task: InstallSSHKey@0
    displayName: Install SSH key for Acquia Cloud
    inputs:
      knownHostsEntry: $(acquia_known_hosts)
      sshPublicKey: $(acquia_public_key)
      sshKeySecureFile: acquia_secret_key

  - script: |
      sudo update-alternatives --set php /usr/bin/php$(phpVersion)
      sudo service mysql start
    displayName: Set PHP and MySQL

  - script: |
      composer validate --no-check-all --ansi
      composer install
    displayName: Install dependencies

  - task: Bash@3
    displayName: Set up environment
    inputs:
      filePath: $(scriptDir)/setup_env.sh

  - task: Bash@3
    displayName: Install the site from config
    inputs:
      filePath: $(scriptDir)/setup_app.sh

  - task: Bash@3
    displayName: Set up Acquia CLI
    inputs:
      filePath: $(scriptDir)/setup_acli.sh
      arguments: $(acli_api_key) $(acli_api_secret) $(acquia_application)

  - task: Bash@3
    displayName: Deploy branch
    condition: and(succeeded(), in(variables['Build.Reason'], 'IndividualCI', 'BatchedCI', 'Schedule'))
    inputs:
      filePath: $(scriptDir)/deploy_branch.sh

  - task: Bash@3
    displayName: Deploy tag
    condition: and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))
    inputs:
      filePath: $(scriptDir)/deploy_tag.sh

Two SSH keys go in first: one so Composer can fetch private packages from GitHub, and one so Acquia CLI can push to the Acquia git remote. The keys themselves are secure files in Azure DevOps, and the known hosts and public keys are pipeline variables.

Checking config with a real install

The CI agent gets a local MySQL database and a git identity for the commits the artifact step will make:

#!/usr/bin/env bash
set -ev

git config --global user.name "Azure-CI"
git config --global user.email "[email protected]"

mysql -u root -proot -e "CREATE DATABASE drupal;
  CREATE USER 'drupal'@'localhost' IDENTIFIED BY 'drupal';
  GRANT ALL ON drupal.* TO 'drupal'@'localhost';"

Then Drush installs a minimal site from the exported configuration. If anything in the config is inconsistent (a missing dependency, a field pointing to a deleted type, a module enabled in config but not in Composer) the install fails. This was the most useful thing BLT did for us, and it is a single Drush command:

#!/usr/bin/env bash
set -ev

# install a throwaway site from the exported config:
# if config is broken, the build fails here instead of on production
vendor/bin/drush @self site:install minimal --existing-config --no-interaction

Acquia CLI in CI

Acquia CLI is a single PHAR. The pipeline downloads it, logs in with an API key and secret, and links the working copy to the right Acquia application:

#!/usr/bin/env bash
set -e

api_key=$1
api_secret=$2
application=$3

curl -OL https://github.com/acquia/cli/releases/latest/download/acli.phar
chmod +x acli.phar
sudo mv acli.phar /usr/local/bin/acli

acli auth:login -k "$api_key" -s "$api_secret" --no-interaction
acli app:link "$application" --no-interaction

One gotcha here: Azure Pipelines writes its own variables as $(name), while in bash the same syntax runs a command. Pass pipeline variables into a script as arguments, and inside the script use plain shell variables. If you mix them up, the login step ends up with empty credentials.

Building and deploying the artifact

acli push:artifact does what BLT's deploy command did. It builds the production version of the codebase, without development dependencies, and commits it to a branch on the Acquia remote. Pushes to a branch create a matching -build branch. Pushing a git tag promotes that build to a tag of the same name.

# deploy_branch.sh: build an artifact and push it to "<branch>-build"
acli push:artifact \
  --destination-git-branch="$BUILD_SOURCEBRANCHNAME-build" \
  --no-interaction

# deploy_tag.sh: promote that build to a tag
acli push:artifact \
  --destination-git-tag="$BUILD_SOURCEBRANCHNAME" \
  --source-git-tag="$BUILD_SOURCEBRANCHNAME-build" \
  --no-interaction

On Site Factory the tag is what you deploy to the stack. Our routine stayed the same as before: merge, wait for the pipeline, then deploy the build from the Site Factory UI. A "code only" deploy takes 10 to 15 minutes. A deploy with database updates takes around an hour and a half on the platform and is scheduled for the evening.

Keeping Site Factory happy

Site Factory needs a set of hook and settings files that its connector module generates. BLT used to manage those. Without BLT, you run the connector's init command whenever the module is updated, and commit the result:

composer update drupal/acsf --with-dependencies
vendor/bin/drush acsf-init --include="docroot/modules/contrib/acsf/acsf_init" --root="docroot" -y
vendor/bin/drush --include=docroot/modules/contrib/acsf/acsf_init acsf-init-verify
# expected: "acsf-init required files ok"

Later we added an ACSF validation step to the pipeline as well, so problems with those files show up in CI instead of during a deploy.

Local development without BLT

Locally, the replacement was even simpler. DDEV installs Acquia CLI in a post-start hook, and pulling a site is one command. I wrote about that setup in Drupal on Windows That Doesn't Hurt.

# inside the DDEV web container
acli pull --no-code    # pick an environment, get its database and files
drush cim -y
drush uli

Where the time went

The gain from 23 minutes to 5 to 9 came from doing less. The new pipeline only runs what we actually need, and each step is one plain command, so a failed run shows you the exact command that failed.

Two things to know if you build something similar. First, the pipeline occasionally fails with a 409. That usually goes away if you run the job again. Second, publishing a release on the main project triggers a deploy build, so do not tag casually.

Should you do the same?

If you are still on BLT, yes, and sooner rather than later. It is not a big project. List what BLT does for you, map each item to Drush or Acquia CLI, and you will probably end up with a pipeline that is shorter, faster and easier to debug than the one you have now.

← All posts