Bastien Vandamme
Bastien Vandamme

Reputation: 18465

How can I cancel all previous build when a new one is queued?

With Azure DevOps how can I cancel the current build when a new one is started?

I want to save my build time. On my master branch sometime I do a merge then another developer do one and another do one again. I don't need to do build by build. I can keep only the latest build. So when a new build is queued this one can cancel all previous build.

Is it possible to setup his build definition to do so?

Upvotes: 16

Views: 15149

Answers (4)

Shayki Abramczyk
Shayki Abramczyk

Reputation: 41625

There is no such feature in Azure DevOps.

The closest thing it's to use "Batching CI builds" - when a build is running, the system wait until the build is completed, then queues another build of all changes that have not yet been built.

To enable it in yaml build add this in the trigger section:

batch: true

In the calssic editor, go to "Triggers" tab and mark the checkbox "Batch changes while a build is in progress".

Edit:

You can run a PowerShell script in the beginning of the build that cancel the running builds from the same definition:

$header = @{ Authorization = "Bearer $env:System_AccessToken" }
$buildsUrl = "$(System.TeamFoundationCollectionUri)$(System.TeamProject)/_apis/builds/builds"
$builds = Invoke-RestMethod -Uri $url -Method Get -Header $header
$buildsToStop = $builds.value.Where({ ($.status -eq 'inProgress') -and ($_.definition.name -eq $(Build.DefinitionName)) -and ($_.id -ne $(Build.BuildId)) })
ForEach($build in $buildsToStop)
{
   $build.status = "Cancelling"
   $body = $build | ConvertTo-Json -Depth 10
   $urlToCancel = "$(System.TeamFoundationCollectionUri)$(System.TeamProject)/_apis/build/builds/$(builds.id)"
   Invoke-RestMethod -Uri $urlToCancel -Method Patch -ContentType application/json -Body $body -Header $header
}

I used OAuth token for authorization (enable it on the job options) and in inline script ($(varName) and not $env:varName).

Now, if you have one build that running and someone else trigger another build that started to run, in this step the first build will be canceled.

Upvotes: 19

Denis
Denis

Reputation: 99

I could not find a solution to cancel the previous build run and all run stages, so I created a template task base on Python API calls.

steps:
    - task: UsePythonVersion@0
      inputs:
        versionSpec: '3.8'
        addToPath: true
        
    - script: |
        python -m pip install --upgrade pip
        pip install requests

    - task: PythonScript@0
      displayName: 'Run a Python script'
      inputs:
        scriptSource: inline
        script: |
            import requests
            import base64
            import json

            TOKEN = '*******************************************'
            authorization = str(base64.b64encode(bytes(':' + TOKEN, 'ascii')), 'ascii')
            headers = {
                'Accept': 'application/json',
                'Authorization': 'Basic ' + authorization
            }

            TeamProject = '$(System.TeamProject)'
            DefinitionName = '$(Build.DefinitionName)'
            organization = '****************************************'
            self_run_id = '$(Build.BuildId)'
            print (f"self_run_id - {self_run_id}")



            def get_url(url) :
                response = requests.get(url=url, headers=headers)
                return response


            def get_current_project_runs():
                pipelines_runs = {}
                current_project_runs = get_url(
                    f"https://dev.azure.com/{organization}/{TeamProject}/_apis/build/builds?statusFilter=inProgress,postponed,notStarted&api-version=7.1-preview.7")

                for run in current_project_runs.json().get('value'):
                    pipeline_name = run.get('definition').get('name')
                    run_id = run.get('id')
                    if pipelines_runs.get(pipeline_name):
                        pipelines_runs.get(pipeline_name).append(run_id)
                    else:
                        pipelines_runs.update({pipeline_name: [run_id]})

                print(f"current_project_runs - {pipelines_runs}")
                return pipelines_runs


            def get_current_pipelines_runs(current_project_runs):
                _current_project_runs = current_project_runs.get(DefinitionName)
                print(f"_pipelines_runs - {_current_project_runs}")
                return _current_project_runs


            def get_stage_names(build_number):
                # buildNumber
                stages_name_list = []
                url = f"https://dev.azure.com/{organization}/{TeamProject}/_apis/build/builds/{build_number}/Timeline"
                current_project_runs = requests.get(url=url, headers=headers)
                for stage_data in current_project_runs.json().get('records'):
                    if stage_data.get('type') == 'Stage':
                        stages_name_list.append(stage_data.get('name'))
                return stages_name_list


            def kill_stages(build_number):
                stages_name_list = get_stage_names(build_number)
                for stage in stages_name_list:
                    kill_stages_url = f"https://dev.azure.com/{organization}/{TeamProject}/_apis/build/builds/{build_number}/stages/{stage}?api-version=7.1-preview.1"
                    stage_kill = requests.patch(url=kill_stages_url, headers=headers, json={"state": "cancel"})
                    print(f"Stage_kill {kill_stages_url} - {stage_kill} - ")

            def kill_duplicate_runs(pipelines_runs):
                if pipelines_runs and len(pipelines_runs) > 1:
                    for run_id in sorted(pipelines_runs)[:-1]:
                        url = f"https://dev.azure.com/{organization}/{TeamProject}/_apis/build/builds/{run_id}?api-version=7.1-preview.7"
                        kill_response = requests.patch(url=url, headers=headers, json={"status": "cancelling"})
                        print(f'kill_response {url} - {kill_response}')
                        kill_stages(run_id)


            current_project_runs = get_current_project_runs()
            pipelines_runs = get_current_pipelines_runs(current_project_runs)
            kill_duplicate_runs(pipelines_runs)

The script checks if it is more than one run of the pipeline, and cancels old runs/stages.

Also, the script can cancel the current run if new ran ware added.

- template: KillPreviousRuns.yml@templates

Upvotes: 2

Dave A-W
Dave A-W

Reputation: 665

The Powershell from the accepted answer contains a couple of typos and no longer works for Azure DevOps late 2021. Here's a current example for version 6.0 of the Azure DevOps API including YAML:

parameters:
- name: cancelPriorDeployments
  displayName: Cancel prior deployments
  type: boolean
  default: true

variables:
  cancelPriorDeployments: '${{ parameters.cancelPriorDeployments }}'
  devOpsApiVersion: 6.0

stages:
- stage: CancelPriorDeploymentsStage
  displayName: Cancel prior deployments
  condition: eq(variables.cancelPriorDeployments, 'true')
  jobs:  
  - job: CancelPriorDeploymentsJob
    displayName: List builds, cancel prior in progress
    pool:
      vmImage: 'windows-latest'
    steps:
      - checkout: none
      - task: PowerShell@2
        displayName: Powershell AzDO Invoke-RestMethod
        env:
          SYSTEM_ACCESSTOKEN: $(System.AccessToken)
        inputs:
          targetType: inline
          script: |
            $header = @{ Authorization = "Bearer $env:SYSTEM_ACCESSTOKEN" }
            $buildsUrl = "$(System.TeamFoundationCollectionUri)$(System.TeamProject)/_apis/build/builds?api-version=$(devOpsApiVersion)"
            Write-Host "GET $buildsUrl"
            $builds = Invoke-RestMethod -Uri $buildsUrl -Method Get -Header $header
            $buildsToStop = $builds.value.Where({ ($_.status -eq 'inProgress') -and ($_.definition.name -eq "$(Build.DefinitionName)") -and ($_.id -lt $(Build.BuildId)) })
            ForEach($build in $buildsToStop)
            {
              $urlToCancel = "$(System.TeamFoundationCollectionUri)$(System.TeamProject)/_apis/build/builds/$($build.id)?api-version=$(devOpsApiVersion)"
              $body = @{ status = "cancelling" } | ConvertTo-Json
              Write-Host "PATCH $urlToCancel"
              Invoke-RestMethod -Uri $urlToCancel -Method Patch -Header $header -ContentType application/json -Body $body
            }

In addition, you will need to grant your build user permissions to "Stop Builds":

  1. Edit your build pipeline

Build Pipeline Edit

  1. Go to Triggers

Pipeline Triggers

  1. Go to Security

Pipeline Security

  1. Scroll down past the Groups section on the left and select your build user from the Users section.
  2. Change Stop builds from Not set to Allow:

Pipeline User Permissions

Without this permission, you'll see an error message similar to:

Access denied. {Your Project} Build Service ({your organisation}) needs Stop builds permissions for vstfs:///Build/Build/12345 in team project {Your Project} to perform the action.

Upvotes: 13

Tomáš Fejfar
Tomáš Fejfar

Reputation: 11217

In you specific use case (building master branch) it seems better to batch the jobs. Usually you don't want to stop master build in the middle.

I came here searching for a way to cancel branch builds when there is a new commit. Eventually I found out that you can use PR builds instead, that are canceled by Github webhook automatically. PR are default and automated, so you only need to stop building branches.

# will not build any branch, only PRs because PR are canceled
# automatically after new commit is pushed
trigger: none

Upvotes: 1

Related Questions