The Build Stage (build.yml)

27 October 2025
Previous Post
Next Post

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
          if docker exec mysql mysqladmin ping -uroot -proot --silent; then
            echo "MySQL is up!"
            break
          fi
          sleep 2
        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)'