The Build Stage (build.yml)

27 October 2025
  1. The Factory & The Assembly Line: A High-Performance Drupal CI/CD Pipeline for Acquia and Azure DevOps
  2. Core Concept 1: The "Locked Box" (Why We Must Use Docker)
  3. Core Concept 2: The "Factory & Assembly Line" (Why Two Pipelines)
  4. Pipeline A: The "Factory" (image-builder-pipeline.yml)
  5. Pipeline B: The "Assembly Line" (Your Main Pipeline)
  6. The Build Stage (build.yml)
  7. The Deploy Stage (deploy.yml)
  8. The Final Piece: How to Trigger PR Builds
  9. Drupal Acquia Azure CI/CD pipeline - Conclusion

This is the most complex template. It downloads the "Locked Box" and then runs a full integration test before zipping up the final code artifact.

Key Features:

  • DownloadPipelineArtifact@2: This task downloads the php-build-[PROJECT-NAME] artifact from the "Factory" pipeline (buildType: 'specific').
  • gunzip -c ... | docker load: This is the other half of our gzip optimization. It decompresses the image in memory and pipes it directly into docker load. This is faster than writing the uncompressed file to disk first.
  • Manual Docker Networking: This is the most critical part. We cannot use the services: block because it creates a race condition (it runs before steps:). The only stable way to get Docker-in-Docker containers to talk is:
    1. Manually create a network: docker network create ci-net.
    2. Manually start mysql: docker run --name mysql --network=ci-net ....
    3. Manually attach all other containers: docker run --network=ci-net .... This adds 15 seconds but guarantees a 100% stable build, which is a worthwhile trade.
  • Inclusion zip: The zip command explicitly lists only the folders needed for deployment. This is safer than an exclusion-based approach, as it ensures no junk files (.DS_Store, .idea) ever make it into the artifact.
# build.yml
parameters:
  displayName: 'Build Drupal'
  jobName: 'Build_Drupal'
  agentPool: 'aws-ubuntu-latest'
  rootDirectory: '$(Build.SourcesDirectory)'
  phpVersion: '8.3' # No longer used to pull, but good for reference
  phpImageName: 'php-build-[PROJECT-NAME]'
  # This new parameter is required - pass the name of your new image-builder pipeline.
  imageBuilderPipelineDefinitionName: ''

jobs:
  - job: ${{ parameters.jobName }}
    displayName: ${{ parameters.displayName }}
    pool: ${{ parameters.agentPool }}
    workspace:
      clean: all

  steps:
    - checkout: self
      submodules: true
      persistCredentials: true
      fetchDepth: 0 # Disables shallow fetch

    # Download the "Locked Box"
    - task: DownloadPipelineArtifact@2
      displayName: 'Download Docker Image Artifact'
      inputs:
        buildType: 'specific'
        project: '$(System.TeamProjectId)'
        definition: '${{ parameters.imageBuilderPipelineDefinitionName }}'
        buildVersionToDownload: 'latest'
        artifactName: '${{ parameters.phpImageName }}'
        targetPath: '$(Pipeline.Workspace)/docker-artifact'

    - script: |
        # Use gunzip to decompress and pipe to docker load
        gunzip -c $(Pipeline.Workspace)/docker-artifact/${{ parameters.phpImageName }}.tar.gz | docker load
      displayName: 'Load Docker Image'

    # === Manual Docker Networking ===
    # This is the only stable way to get Docker-in-Docker containers to talk.
    - script: 'docker network create ci-net || true'
      displayName: 'Create Docker Network'

    - task: Cache@2
      displayName: 'Cache Composer dependencies'
      inputs:
        key: 'composer | "$(Agent.OS)" | composer.lock'
        restoreKeys: composer | "$(Agent.OS)"
        path: '$(Build.SourcesDirectory)/vendor'

    - script: |
        docker run --rm --network=ci-net -e CI=true -e PIPELINE_ENV=ci -v $(Build.SourcesDirectory):/app -w /app ${{ parameters.phpImageName }} bash -c "composer validate --no-check-all --ansi && composer install --optimize-autoloader"
      displayName: 'Install dependencies (Composer)'

    - task: Bash@3
      displayName: 'Validate code'
      inputs:
        targetType: 'inline'
        script: |
          docker run --rm --network=ci-net -e CI=true -e PIPELINE_ENV=ci -v $(Build.SourcesDirectory):/app -w /app ${{ parameters.phpImageName }} bash ci/scripts/build/validate.sh
      env:
        COMPOSER_ALLOW_SUPERUSER: 1

    # Manually start MySQL and attach it to our network
    - script: |
        docker rm -f mysql || true
        docker run --name mysql --network=ci-net -e CI=true -e PIPELINE_ENV=ci -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=drupal -d mysql:8.0
        echo "Waiting for MySQL to initialize..."
        for i in {1..30}; do
          # Use the 'mysql' client to run a simple, silent query.
          # This is more reliable than 'mysqladmin ping'.
          if mysql --ssl=FALSE -h mysql -u root -proot -e "SELECT 1;" > /dev/null 2>&1; then
            echo "MySQL is up!"
            break
          fi

          if [ $i -eq 30 ]; then
            echo "MySQL failed to start after 30 seconds."
            exit 1
          fi
          sleep 1
        done
      displayName: 'Start MySQL Docker Container'

    # Run integration tests, which can now find 'mysql' on the 'ci-net' network
    - task: Bash@3
      displayName: 'Prepare MySQL for usage'
      inputs:
        targetType: 'inline'
        script: |
          docker run --rm --network=ci-net -e CI=true -e PIPELINE_ENV=ci -v $(Build.SourcesDirectory):/app -w /app ${{ parameters.phpImageName }} bash ci/scripts/build/mysql.sh

    - task: Bash@3
      displayName: 'Install web app & apply configs'
      inputs:
        targetType: 'inline'
        script: |
          docker run --rm --network=ci-net -e CI=true -e PIPELINE_ENV=ci -v $(Build.SourcesDirectory):/app -w /app ${{ parameters.phpImageName }} bash -c "
            export PATH=\"/app/vendor/bin:\$PATH\" && \
            export MYSQL_CLIENT_FLAGS="--ssl-mode=DISABLED" && \
            composer drupal:install --no-interaction --verbose && \
            ./vendor/bin/drush -y updb && \
            ./vendor/bin/drush -y cim"
      env:
        COMPOSER_ALLOW_SUPERUSER: 1
        CI: 'true'
        PIPELINE_ENV: 'ci'

    # Build the final application artifact
    - script: |
        set -e
        cd $(Build.SourcesDirectory)
        mkdir -p $(Build.ArtifactStagingDirectory)
        # Your safer, inclusion-based zip command
        zip -r $(Build.ArtifactStagingDirectory)/$(Build.Repository.Name).zip \
          .gitignore \
          composer.json \
          composer.lock \
          salt.txt \
          ci \
          config \
          docroot \
          hooks
      displayName: 'Build Artifact'

    # Publish the final application artifact
    - task: PublishBuildArtifacts@1
      displayName: 'Publish Build Artifact'
      condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'))
      inputs:
        PathtoPublish: '$(Build.ArtifactStagingDirectory)/$(Build.Repository.Name).zip'
        ArtifactName: '$(Build.Repository.Name)'