OFQ-00004 Build Pipelines for Digital Infrastructure
Last updated: 1 October 2025
This standard covers our requirements for how Build Pipelines should be configured within Ofqual-developed services.
Requirement(s)
- Build pipelines MUST run SAST Analysis before building artifacts
- Build pipelines MUST run automated tests before building artifacts
- Build pipelines MUST build and publish an appropriate artifact
Build pipelines MUST run SAST Analysis before building artifacts
The first stage should always be to set up static analysis. This is set up to run one job:
- Snyk, a SAST Analysis tool that checks code for vulnerabilities
Snyk is currently the standard scanning tool we use to satisfy the Security in First Party Software standard.
Some pipelines may choose to implement additional tooling, such as Sonarqube and internal build tool scanning (e.g. dotnet list package --vulnerable). These are optional and are not covered in this standard document. You should only implement such additional tooling with the approval of the Lead Developer and Security Team.
Configuring the YAML
As good practice we should save the endpoint needed for authentication and the token(api key) as pipeline variables:
$(SNYK_ENDPOINT)=https://app.eu.snyk.io/api$(SNYK_TOKEN)= this can be retrieved this from a pipeline that already has Snyk implemented
WARNING
- Use a separate stage for this. There should be one job configured:
- A job for running Snyk
- See the snippets below for example implementations
- Only include the
Build Docker Image&containertasks if there is a working Dockerfile included in the repo; static applications do not use Docker files
Front-End Example (React / Static Web Apps)
NB: Don’t just copy and paste this! This is an example from 29/04/2025 - it may be out of date (e.g. Node version spec)
- stage: StaticAnalysis
displayName: "Run Static Analysis"
condition: always()
jobs:
- job: Snyk
displayName: 'Build and Snyk Analysis'
pool:
vmImage: ubuntu-latest
steps:
- task: NodeTool@0
inputs:
versionSpec: "22.x"
displayName: "Install Node.js"
- task: Npm@1
displayName: "npm install"
inputs:
command: 'install'
customRegistry: 'useFeed'
customFeed: 'b8db0229-c220-4583-b1d9-1111e482a1ce'
- task: Npm@1
displayName: 'npm build dist'
inputs:
command: custom
verbose: false
customCommand: "run dist"
# Install and authenticate Snyk
- script: |
npm install -g snyk
snyk config set endpoint=$(SNYK_ENDPOINT)
snyk auth $(SNYK_TOKEN)
set +e
displayName: 'Snyk Install & Auth'
- task: SnykSecurityScan@1
displayName: 'Synk code scan'
inputs:
testType: 'code'
serviceConnectionEndpoint: 'snyk-integration-eu'
codeSeverityThreshold: 'high'
failOnIssues: true
- task: SnykSecurityScan@1
displayName: 'Synk app scan'
inputs:
testType: 'app'
serviceConnectionEndpoint: 'snyk-integration-eu'
monitorWhen: 'always'
severityThreshold: 'high'
failOnIssues: true
additionalArguments: '--all-projects'
- task: Docker@2 # not needed for static apps
displayName: Build Docker Image
inputs:
command: build
repository: 'repository'
dockerfile: '$(dockerfilePath)'
tags: 'latest'
- task: SnykSecurityScan@1 # not needed for static apps
displayName: 'Synk container scan'
inputs:
serviceConnectionEndpoint: 'snyk-integration-eu'
testType: 'container'
dockerImageName: 'repository:latest'
dockerfilePath: '$(dockerfilePath)'
monitorWhen: 'always'
severityThreshold: 'high'
failOnIssues: true
Back-End Example (C#)
NB: Don’t just copy and paste this! This is an example from 29/04/2025 - it may be out of date (e.g. Node version spec)
- stage: StaticAnalysis
displayName: "Run Static Analysis"
jobs:
- job: SonarQube
displayName: 'Build and SonarQube Analysis'
pool:
vmImage: ubuntu-latest
steps:
- task: UseDotNet@2
displayName: 'Install .NET Core SDK'
inputs:
version: 8.x
performMultiLevelLookup: true
includePreviewVersions: true
- task: DotNetCoreCLI@2
displayName: "Restore task"
inputs:
command: 'restore'
projects: '**/*.csproj'
feedsToUse: 'select'
vstsFeed: 'b8db0229-c220-4583-b1d9-1111e482a1ce'
- task: DotNetCoreCLI@2
displayName: "Build task"
inputs:
command: "build"
projects: "**/*.csproj"
arguments: "--configuration $(BuildConfiguration)"
# Install and authenticate Snyk
- script: |
npm install -g snyk
snyk config set endpoint=$(SNYK_ENDPOINT)
snyk auth $(SNYK_TOKEN)
set +e
displayName: 'Snyk Install & Auth'
- task: SnykSecurityScan@1
displayName: 'Synk code scan'
inputs:
serviceConnectionEndpoint: 'snyk-integration-eu'
testType: 'code'
codeSeverityThreshold: 'high'
failOnIssues: true
- task: SnykSecurityScan@1
displayName: 'Synk app scan'
inputs:
serviceConnectionEndpoint: 'snyk-integration-eu'
testType: 'app'
monitorWhen: 'always'
severityThreshold: 'high'
failOnIssues: true
additionalArguments: '--all-projects'
- task: Docker@2
displayName: Build Docker Image
inputs:
command: build
repository: 'register-frontend'
dockerfile: '$(dockerfilePath)'
tags: 'latest'
- task: SnykSecurityScan@1
displayName: 'Synk container scan'
inputs:
serviceConnectionEndpoint: 'snyk-integration-eu'
testType: 'container'
dockerImageName: 'register-frontend:latest'
dockerfilePath: '$(dockerfilePath)'
monitorWhen: 'always'
severityThreshold: 'high'
failOnIssues: true
Build pipelines MUST run automated tests before building artifacts
A ‘Run Tests’ stage should always be present, and execute appropriate test suites based on the repository. Generally speaking, this can be:
- Some form of E2E testing (on new systems, Playwright)
- Some form of unit and/or integration testing
If tests fail, they should always block the pipeline from passing. Tests should always be ran regardless of Static Analysis results.
Exceptions to this requirement should only be approved by the Lead Developer and will generally only be approved on legacy equipment that cannot be maintained or for basic static apps; no exceptions will be approved for newly-written software
Example Snippets
NB: Don’t just copy and paste this! This is an example from 26/02/2024 - it may be out of date (e.g. Node version spec) and in most cases should just be used as a reference
Playwright (React)
- job: Playwright
condition: always()
dependsOn: []
displayName: "Playwright"
steps:
- task: NodeTool@0
inputs:
versionSpec: "18.x"
displayName: "Install Node.js"
- task: Npm@1
displayName: "npm install"
inputs:
command: "install"
customRegistry: "useFeed"
customFeed: "b8db0229-c220-4583-b1d9-1111e482a1ce"
- script: |
npx playwright install --with-deps
displayName: "install playwright"
- script: |
npx playwright test
env:
TEST_DB_CONN: $(DB_CONN)
displayName: "Run playwright"
- publish: $(System.DefaultWorkingDirectory)/playwright-report
artifact: playwright-report
# always create the artifact, this is useful for debugging failed tests
condition: always()
displayName: "Publish playwright report"
- task: PublishTestResults@2
displayName : "Publish playwright results"
inputs:
testResultsFormat: 'JUnit'
testResultsFiles: '**/results.xml'
failTaskOnFailedTests: true
testRunTitle: 'playwright-results-$(Build.BuildId)'
Unit Tests (Front End, React)
- job: UnitTests
condition: always()
dependsOn: []
displayName: "Unit tests"
steps:
- task: NodeTool@0
inputs:
versionSpec: "18.x"
displayName: "Install Node.js"
- task: Npm@1
displayName: "npm install"
inputs:
command: "install"
customRegistry: "useFeed"
customFeed: "b8db0229-c220-4583-b1d9-1111e482a1ce"
- task: Npm@1
displayName: "npm run test"
inputs:
command: "custom"
customCommand: "run test:ci"
- task: PublishTestResults@2
displayName: "Publish unit test results"
inputs:
testResultsFormat: "JUnit"
testResultsFiles: "**/junit.xml"
failTaskOnFailedTests: true
testRunTitle: "unit-test-results-$(Build.BuildId)"
Unit Tests (Back-End / C#)
- job: UnitTests
condition: always()
dependsOn: []
displayName: "Unit tests"
steps:
- task: UseDotNet@2
inputs:
packageType: "sdk"
version: "3.1.x"
- task: DotNetCoreCLI@2
displayName: "Restore task"
inputs:
command: "restore"
projects: "**/*.csproj"
feedsToUse: "config"
nugetConfigPath: "nuget.config"
- task: DotNetCoreCLI@2
displayName: "Build task"
inputs:
command: "build"
projects: "**/*.csproj"
arguments: "--configuration $(BuildConfiguration)"
- task: DotNetCoreCLI@2
env:
OdsContainer__Endpoint: $(OdsContainer__Endpoint)
OdsContainer__Username: $(OdsContainer__Username)
OdsContainer__Password: $(OdsContainer__Password)
OdsContainer__SqlPassword: $(OdsContainer__SqlPassword)
displayName: "Run unit tests"
inputs:
command: 'test'
projects: '**/*Tests.csproj'
arguments: '--filter "(Category=Unit)|(Category=Integration)"'
testRunTitle: 'Ofqual.Communications_$(Build.BuildNumber)'
Build pipelines MUST build and publish an appropriate artifact
The last stage should finally build and publish out a build.
- Front-End releases should only do this on tagged builds
- Back-End releases should only do this on main and release branch builds
- You may need to reconfigure the corresponding release pipeline to achieve this
The type of artifact required will depend upon the deployed infrastructure:
- New systems should always be built using Docker Images instead of pure build artifacts
- Function Apps require specific YAML stages to be deployed
- Legacy systems typically require a specific build artifact that is then published to the artifact registry
Docker Images
Introduction
Docker is currently our standard way of creating new services
Key notes for this YAML:
- You must publish to separate registries; the release pipelines and services can only see registries on their same subscription which necessitates multiple registries
- The name of the image should be the same across both registries for configuration consistency
- Do not run a build and publish at all for pull requests (unless we can figure out a way of building without a publish). We could consider a registry or naming convention dedicated to PRs if QA or Devs find it useful to build and store images for them
- Do not build and publish to the prod registry unless on a release branch; this prevents accidental pushes of main to prod
- Always publish both to latest and an appropriate tag for historical and rollback purposes
Example Snippets
NB: Don’t just copy and paste this! These are examples from 23/07/2025 - it may be out of date (e.g. Node version spec) and in most cases should just be used as a reference
Environment Variables for this YAML:
dockerRegistryServiceConnectionDev:CROFQAPPdev1-FederateddockerRegistryServiceConnectionProd:CROFQPORTAL-FederatedimageRepository:ofqual/{product name}-{type}where{product-name}is the name of the product (e.g. RefData, SOC, PFS), and {type} is either API or Frontend
- stage: Build
displayName: Build and push stage
jobs:
- job: BuildDev
condition: and(succeeded(), not(startsWith(variables['build.sourceBranch'], 'refs/pull')))
displayName: Build for Dev
pool:
vmImage: $(vmImageName)
steps:
- task: Docker@2
displayName: Build and push an image to dev container registry
inputs:
command: buildAndPush
repository: $(imageRepository)
containerRegistry: $(dockerRegistryServiceConnectionDev)
dockerfile: $(dockerfilePath)
tags: |
latest
$(tag)
- job: BuildProd
condition: and(succeeded(), startsWith(variables['build.sourceBranch'], 'refs/heads/releases'))
displayName: Build for Production
pool:
vmImage: $(vmImageName)
steps:
- task: Docker@2
displayName: Build and push an image to production container registry
inputs:
command: buildAndPush
repository: $(imageRepository)
containerRegistry: $(dockerRegistryServiceConnectionProd)
dockerfile: $(dockerfilePath)
tags: |
latest
$(tag)
Function Apps
Introduction
- Sometimes it is more suitable to use a function app over a Docker Image, such as when capacity is forecast to need to rapidly increase and decrease over time (thus requiring flexible scaling up and down to optimise costs)
- This is generally done by exception and should be approved by the Lead Developer before being user.
Example Snippets
NB: Don’t just copy and paste this! These are examples from 23/07/2025 - it may be out of date (e.g. Node version spec) and in most cases should just be used as a reference
- stage: Deploy
displayName: Deploy stage
dependsOn: BuildAndPackage
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
# Deploy job: This job deploys the build artifact to the Azure Function App.
- deployment: Deploy
displayName: Deploy
environment: 'development'
pool:
vmImage: $(vmImageName)
strategy:
runOnce:
deploy:
steps:
# Deploy to Azure Function App
- task: AzureFunctionApp@2
inputs:
connectedServiceNameARM: $(ConnectedServiceName)
appType: 'functionApp'
appName: $(FunctionAppName)
package: '$(Pipeline.Workspace)/drop/$(Build.BuildId).zip'
deploymentMethod: 'auto'
Static Applications
Introduction
- For lightweight static applications, such as those used for our standards and patterns site and our shutter pages, it is suitable to deploy out directly to a static application
- Unlike our other applications, these build and deploy out to production on main rather than building and then using a release pipeline; this is to maintain the intended simplicity of these static apps.
- Note that a lot of the configuration for static apps is determined by the package.json (e.g. the build engine used)
- The deployment token is a secret and is obtained via the Azure Static Web App to be deployed onto.
Example Snippet
NB: Don’t just copy and paste this! These are examples from 01/10/2025 - it may be out of date (e.g. Node version spec) and in most cases should just be used as a reference
- stage: BuildAndDeploy
jobs:
- job: BuildProd
condition: and(succeeded(), startsWith(variables['build.sourceBranch'], 'refs/heads/main'))
steps:
- checkout: self
submodules: true
- task: AzureStaticWebApp@0
inputs:
app_location: '/' # App source code path relative to cwd
api_location: '' # Api source code path relative to cwd
output_location: '_site' # Built app content directory; in eleventy this is _site, and in vite this is the dist folder
azure_static_web_apps_api_token: $(deployment_token)
Legacy Systems using App Services (DEPRECATED)
Note: This section of the standard is deprecated. This should only be used on legacy systems that cannot be moved to Docker; do not use this for new systems
Example Snippets
NB: Don’t just copy and paste this! These are examples from 26/02/2024 - it may be out of date (e.g. Node version spec) and in most cases should just be used as a reference
Back-End (C#)
- stage: BuildAndPackage
dependsOn: RunTests
displayName: "Build and package"
jobs:
- job: PublishArtifact
displayName: "Publish Artifact"
steps:
- task: UseDotNet@2
displayName: 'Install .NET Core SDK'
inputs:
version: 6.x
performMultiLevelLookup: true
includePreviewVersions: true # Required for preview versions
- task: DotNetCoreCLI@2
displayName: "Restore task"
inputs:
command: "restore"
projects: "**/*.csproj"
feedsToUse: "select"
vstsFeed: "b8db0229-c220-4583-b1d9-1111e482a1ce"
- task: DotNetCoreCLI@2
displayName: "Build task"
inputs:
command: "build"
projects: "**/*.csproj"
arguments: "--configuration $(BuildConfiguration)"
- task: DotNetCoreCLI@2
displayName: "Publish task"
inputs:
command: publish
publishWebProjects: True
arguments: '--configuration $(BuildConfiguration) --output "$(build.artifactstagingdirectory)"'
zipAfterPublish: True
- task: PublishBuildArtifacts@1
displayName: "Publish Artifact"
inputs:
PathtoPublish: "$(build.artifactstagingdirectory)"
Front End (React)
- stage: BuildAndPackage
dependsOn: RunTests
condition: and(succeeded(), startsWith(variables['build.sourceBranch'], 'refs/tags/v'))
displayName: "Build and package"
jobs:
- job: PublishPackage
displayName: "Publish Package"
steps:
- task: NodeTool@0
inputs:
versionSpec: "18.x"
displayName: "Install Node.js"
- task: Npm@1
displayName: "npm install"
inputs:
command: "install"
customRegistry: "useFeed"
customFeed: "b8db0229-c220-4583-b1d9-1111e482a1ce"
- task: Npm@1
displayName: "npm build dist"
inputs:
command: custom
verbose: false
customCommand: "run dist"
- task: Npm@1
displayName: "npm pack"
inputs:
command: custom
verbose: false
customCommand: pack
- task: Npm@1
displayName: "npm publish"
inputs:
command: publish
verbose: false
publishRegistry: useFeed
publishFeed: "b8db0229-c220-4583-b1d9-1111e482a1ce"