Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions .azuredevops/pipelines/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ stages:
steps:
- checkout: self
clean: true
fetchDepth: 0
fetchTags: true
- task: UseDotNet@2
displayName: Use .NET SDK 9.0.x
inputs:
Expand Down Expand Up @@ -217,15 +219,22 @@ stages:
$acrLoginServer = '$(acrLoginServer)'
if ([string]::IsNullOrWhiteSpace($acrLoginServer)) { throw 'The suffixed ACR login-server output is absent.' }
$acrName = $acrLoginServer.Split('.')[0]
$version = & ./scripts/version/Get-BuildVersion.ps1
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($version)) { throw 'Semantic version computation failed.' }
Write-Host "##vso[task.setvariable variable=buildVersion;isOutput=true]$version"
Write-Host "Computed semantic version: $version"
$commitTag = "$acrLoginServer/$(imageRepository):$(Build.SourceVersion)"
$latestTag = "$acrLoginServer/$(imageRepository):latest"
$versionTag = "$acrLoginServer/$(imageRepository):v$version"

az acr login --name $acrName
if ($LASTEXITCODE -ne 0) { throw 'Workload-identity ACR login failed.' }
docker build src/webapp01 --file src/webapp01/Dockerfile --tag $commitTag --tag $latestTag
docker build src/webapp01 --file src/webapp01/Dockerfile --build-arg APP_VERSION=$version --tag $commitTag --tag $versionTag --tag $latestTag
if ($LASTEXITCODE -ne 0) { throw 'Container image build failed.' }
docker push $commitTag
if ($LASTEXITCODE -ne 0) { throw 'Commit image push failed.' }
docker push $versionTag
if ($LASTEXITCODE -ne 0) { throw 'Version image push failed.' }
docker push $latestTag
if ($LASTEXITCODE -ne 0) { throw 'Convenience image push failed.' }

Expand Down Expand Up @@ -330,6 +339,7 @@ stages:
acrLoginServer: $[ stageDependencies.Provision.Deploy.outputs['deployInfrastructure.acrLoginServer'] ]
imageReference: $[ stageDependencies.BuildEvidence.BuildSignVerify.outputs['createEvidence.imageReference'] ]
sbomDigest: $[ stageDependencies.BuildEvidence.BuildSignVerify.outputs['createEvidence.sbomDigest'] ]
buildVersion: $[ stageDependencies.BuildEvidence.BuildSignVerify.outputs['createEvidence.buildVersion'] ]
jobs:
- deployment: DeployWebApp
displayName: Deploy Web App by digest
Expand All @@ -340,6 +350,7 @@ stages:
steps:
- checkout: self
clean: true
persistCredentials: true
- task: DownloadPipelineArtifact@2
displayName: Download signed image evidence
inputs:
Expand Down Expand Up @@ -416,4 +427,25 @@ stages:
Write-Host "Deployed immutable image: $(imageReference)"
Write-Host "Stable Web App endpoint: $(webAppUrl)"
Write-Host "Signed evidence artifact: signed-image-evidence"
displayName: Emit deployment summary
displayName: Emit deployment summary
- pwsh: |
$ErrorActionPreference = 'Stop'
$version = '$(buildVersion)'
if ([string]::IsNullOrWhiteSpace($version)) { throw 'Semantic version output is absent.' }
$tag = "v$version"
$existing = git ls-remote --tags origin "refs/tags/$tag"
if (-not [string]::IsNullOrWhiteSpace($existing)) {
Write-Host "Tag $tag already exists on origin; skipping."
return
}
git config user.email 'build-service@devopsabcs.com'
git config user.name 'Azure DevOps Build Service'
git tag -a $tag -m "Release $tag"
git push origin $tag
if ($LASTEXITCODE -ne 0) {
Write-Host "##vso[task.logissue type=warning]Unable to push git tag $tag. Grant the build service 'Contribute' permission to enable version tagging."
}
else {
Write-Host "Created and pushed git tag $tag."
}
displayName: Create and push semantic version git tag
50 changes: 44 additions & 6 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,24 @@ env:
DOTNET_VERSION: "9.0.x" # set this to the dot net version to use

jobs:
version:
name: Compute semantic version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.compute.outputs.version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # full history + tags required for deterministic version computation

- name: Compute build version
id: compute
shell: pwsh
run: |
$version = & ./scripts/version/Get-BuildVersion.ps1
Write-Host "Computed semantic version: $version"
echo "version=$version" >> $env:GITHUB_OUTPUT

deploy-infrastructure:
name: Deploy Azure Infrastructure
runs-on: ubuntu-latest
Expand Down Expand Up @@ -149,11 +167,12 @@ jobs:

cicd:
name: Build and Deploy to Azure Web App
needs: deploy-infrastructure
needs: [version, deploy-infrastructure]
runs-on: ubuntu-latest
env:
AZURE_ACR_NAME: ${{ needs.deploy-infrastructure.outputs.acr_name }}
AZURE_WEBAPP_NAME: ${{ needs.deploy-infrastructure.outputs.webapp_name }}
APP_VERSION: ${{ needs.version.outputs.version }}
steps:
# Checkout the repo
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down Expand Up @@ -191,16 +210,33 @@ jobs:

- name: Build and Push Docker Image
run: |
docker build ./src/webapp01 --file ./src/webapp01/Dockerfile -t ${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:${{ github.sha }}
docker tag ${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:${{ github.sha }} ${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:latest
docker build ./src/webapp01 --file ./src/webapp01/Dockerfile \
--build-arg APP_VERSION=${{ env.APP_VERSION }} \
-t ${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:${{ github.sha }} \
-t ${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:v${{ env.APP_VERSION }} \
-t ${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:latest
docker push ${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:${{ github.sha }}
docker push ${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:v${{ env.APP_VERSION }}
docker push ${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:latest

- name: Azure Web Apps Deploy
uses: azure/webapps-deploy@8db8b8d14f21b245e6706fd0607244e354884697 # v3
with:
app-name: ${{ env.AZURE_WEBAPP_NAME }}
images: "${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:${{ github.sha }}"
images: "${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:v${{ env.APP_VERSION }}"

- name: Create and push semantic version git tag
shell: pwsh
run: |
$tag = "v${{ env.APP_VERSION }}"
$remote = & git ls-remote --tags origin "refs/tags/$tag"
if (-not [string]::IsNullOrWhiteSpace($remote)) {
Write-Host "Tag $tag already exists on origin; skipping."
exit 0
}
& git tag -a $tag -m "Release $tag"
& git push origin $tag
Write-Host "Created and pushed git tag $tag."

- name: Add deployment link to summary
run: |
Expand All @@ -211,7 +247,8 @@ jobs:
echo "| Resource | Value |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| URL | ${{ needs.deploy-infrastructure.outputs.webapp_url }} |" >> $GITHUB_STEP_SUMMARY
echo "| Image | \`${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:${{ github.sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`v${{ env.APP_VERSION }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Image | \`${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:v${{ env.APP_VERSION }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Commit | \`${{ github.sha }}\` |" >> $GITHUB_STEP_SUMMARY

- name: logout
Expand All @@ -221,10 +258,11 @@ jobs:
# https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-and-reusable-workflows-to-achieve-slsa-v1-build-level-3
container-build-publish:
name: Build and Publish Container Image
needs: version
uses: devopsabcs-engineering/devsecops-reusable-workflows/.github/workflows/container.yml@main
with:
# This is used for tagging the container image
version: v1.0.0
version: v${{ needs.version.outputs.version }}
container-file: ./src/webapp01/Dockerfile
container-context: ./src/webapp01
container-name: "${{ github.repository }}/webapp01"
53 changes: 51 additions & 2 deletions .gitlab/ci/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,17 +82,32 @@ cicd:build-and-push:
needs:
- job: cicd:deploy-infrastructure
artifacts: true
variables:
GIT_DEPTH: 0 # full history + tags for deterministic version computation
id_tokens:
AZURE_OIDC_TOKEN:
aud: api://AzureADTokenExchange
script:
- az login --service-principal --username "$AZURE_CLIENT_ID" --tenant "$AZURE_TENANT_ID" --federated-token "$AZURE_OIDC_TOKEN" --allow-no-subscriptions
- az account set --subscription "$AZURE_SUBSCRIPTION_ID"
- ACR_NAME="${ACR_LOGIN_SERVER%%.*}"
- az acr build --registry "$ACR_NAME" --image "$IMAGE_NAME:$CI_COMMIT_SHA" --target final --file src/webapp01/Dockerfile src/webapp01
# Semantic version, mirroring scripts/version/Get-BuildVersion.ps1 (1.0.0, patch++ per tagged
# commit). Computed inline because the fleet runner image provides git but not PowerShell.
- |
BUILD_VERSION="1.0.0"
if command -v git >/dev/null 2>&1; then
ON_HEAD=$(git tag --points-at HEAD --list 'v1.0.*' 2>/dev/null | sed -n 's/^v1\.0\.\([0-9][0-9]*\)$/\1/p' | sort -n | tail -1)
HIGHEST=$(git tag --list 'v1.0.*' 2>/dev/null | sed -n 's/^v1\.0\.\([0-9][0-9]*\)$/\1/p' | sort -n | tail -1)
if [ -n "$ON_HEAD" ]; then BUILD_VERSION="1.0.$ON_HEAD";
elif [ -n "$HIGHEST" ]; then BUILD_VERSION="1.0.$((HIGHEST + 1))";
else BUILD_VERSION="1.0.0"; fi
fi
echo "Computed semantic version: $BUILD_VERSION"
- az acr build --registry "$ACR_NAME" --image "$IMAGE_NAME:$CI_COMMIT_SHA" --image "$IMAGE_NAME:v$BUILD_VERSION" --build-arg APP_VERSION="$BUILD_VERSION" --target final --file src/webapp01/Dockerfile src/webapp01
- IMAGE_DIGEST="$(az acr repository show --name "$ACR_NAME" --image "$IMAGE_NAME:$CI_COMMIT_SHA" --query digest --output tsv)"
- test -n "$IMAGE_DIGEST"
- echo "IMAGE_REFERENCE=$ACR_LOGIN_SERVER/$IMAGE_NAME@$IMAGE_DIGEST" > image.env
- echo "BUILD_VERSION=$BUILD_VERSION" >> image.env
artifacts:
expire_in: 30 days
reports:
Expand Down Expand Up @@ -168,4 +183,38 @@ cicd:deploy-webapp:
- az login --service-principal --username "$AZURE_CLIENT_ID" --tenant "$AZURE_TENANT_ID" --federated-token "$AZURE_OIDC_TOKEN" --allow-no-subscriptions
- az account set --subscription "$AZURE_SUBSCRIPTION_ID"
- az webapp config container set --resource-group "$RESOURCE_GROUP_NAME" --name "$WEB_APP_NAME" --docker-custom-image-name "$IMAGE_REFERENCE"
- curl --fail --retry 12 --retry-delay 10 --retry-all-errors "$WEB_APP_URL"
- curl --fail --retry 12 --retry-delay 10 --retry-all-errors "$WEB_APP_URL"

# Creates the semantic version git tag in source control (parity with GitHub/ADO).
# Non-fatal: requires a GITLAB_TAG_TOKEN (project access token with write_repository).
release:tag:
extends:
- .rules:main-and-manual
stage: deploy
image: mcr.microsoft.com/azure-cli:2.71.0
needs:
- job: cicd:build-and-push
artifacts: true
- job: cicd:deploy-webapp
variables:
GIT_DEPTH: 0
allow_failure: true
script:
- test -n "$BUILD_VERSION"
- git config --global --add safe.directory "$CI_PROJECT_DIR"
- |
if git ls-remote --tags origin "refs/tags/v$BUILD_VERSION" | grep -q "v$BUILD_VERSION"; then
echo "Tag v$BUILD_VERSION already exists on origin; skipping."
exit 0
fi
- |
if [ -z "$GITLAB_TAG_TOKEN" ]; then
echo "GITLAB_TAG_TOKEN is not set; cannot push tag v$BUILD_VERSION. Define a project access token with write_repository to enable version tagging."
exit 0
fi
- git config user.email "gitlab-ci@devopsabcs.com"
- git config user.name "GitLab CI"
- git remote set-url origin "https://oauth2:${GITLAB_TAG_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
- git tag -a "v$BUILD_VERSION" -m "Release v$BUILD_VERSION"
- git push origin "v$BUILD_VERSION"
- echo "Created and pushed git tag v$BUILD_VERSION."
104 changes: 104 additions & 0 deletions scripts/version/Get-BuildVersion.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#Requires -Version 7.0
<#
.SYNOPSIS
Computes the deterministic semantic build version shared by all three CI/CD providers
(GitHub Actions, Azure DevOps, GitLab CI).

.DESCRIPTION
The version scheme is MAJOR.MINOR.PATCH starting at 1.0.0. The patch component is derived
from the highest existing 'vMAJOR.MINOR.*' git tag plus one, so every tagged pipeline run on
the default branch increments the patch by exactly one:

(no tags) -> 1.0.0
v1.0.0 exists -> 1.0.1
v1.0.1 exists -> 1.0.2 ...

If the current commit (HEAD) is already tagged with a matching version, that version is reused
so pipeline re-runs are idempotent and never produce a duplicate or skipped patch.

The computation depends only on git tags, so it produces identical results on every provider
without any external tooling. Pipelines must fetch the full history and tags (for example
'fetch-depth: 0' on GitHub, 'fetch: 0' / unshallow on Azure DevOps, and 'GIT_DEPTH: 0' on
GitLab) so the tag list is complete.

.PARAMETER Major
Major version component. Defaults to 1.

.PARAMETER Minor
Minor version component. Defaults to 0.

.PARAMETER CreateTag
When set, creates the annotated tag 'vMAJOR.MINOR.PATCH' locally (if it does not already
exist). Pushing the tag is left to the caller so provider-specific credentials are used.

.OUTPUTS
System.String. The computed semantic version (for example '1.0.3').
#>
[CmdletBinding()]
param(
[int]$Major = 1,
[int]$Minor = 0,
[switch]$CreateTag
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Get-NextBuildVersion {
param(
[int]$Major,
[int]$Minor
)

$prefix = "v$Major.$Minor."
$head = (& git rev-parse HEAD 2>$null)
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($head)) {
throw 'Unable to resolve HEAD commit. Run this script inside a git working tree with full history.'
}
$head = $head.Trim()

# Reuse a version already tagged on HEAD to keep pipeline re-runs idempotent.
$onHead = @(& git tag --points-at $head --list "$prefix*")
$reusePatches = @(
$onHead |
ForEach-Object { $_.Substring($prefix.Length) } |
Where-Object { $_ -match '^\d+$' } |
ForEach-Object { [int]$_ }
)
if ($reusePatches.Count -gt 0) {
$patch = ($reusePatches | Measure-Object -Maximum).Maximum
return "$Major.$Minor.$patch"
}

# Otherwise take the highest existing patch for this MAJOR.MINOR and increment it.
$allTags = @(& git tag --list "$prefix*")
$patches = @(
$allTags |
ForEach-Object { $_.Substring($prefix.Length) } |
Where-Object { $_ -match '^\d+$' } |
ForEach-Object { [int]$_ }
)
if ($patches.Count -gt 0) {
$next = ($patches | Measure-Object -Maximum).Maximum + 1
}
else {
$next = 0
}

return "$Major.$Minor.$next"
}

$version = Get-NextBuildVersion -Major $Major -Minor $Minor

if ($CreateTag) {
$tag = "v$version"
$existing = @(& git tag --list $tag)
if ($existing.Count -eq 0) {
& git tag -a $tag -m "Release $tag" | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Failed to create git tag $tag."
}
}
}

Write-Output $version
4 changes: 4 additions & 0 deletions src/webapp01/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ RUN dotnet publish "./webapp01.csproj" -c $BUILD_CONFIGURATION -o /app/publish /

# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration)
FROM base AS final
# Semantic build version stamped by the CI/CD pipelines (GitHub, Azure DevOps, GitLab).
# Defaults to 0.0.0 so source-only builds (e.g. container security scans) still succeed.
ARG APP_VERSION=0.0.0
ENV APP_VERSION=$APP_VERSION
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "webapp01.dll"]
5 changes: 3 additions & 2 deletions src/webapp01/Pages/Shared/_Layout.cshtml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<!DOCTYPE html>
@inject Microsoft.Extensions.Configuration.IConfiguration Configuration
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
Expand Down Expand Up @@ -44,7 +45,7 @@

<footer class="border-top footer text-muted">
<div class="container">
&copy; 2025 - webapp01 - <a asp-area="" asp-page="/Privacy">Privacy</a>
&copy; 2025 - webapp01 - v@(Configuration["APP_VERSION"] ?? "dev") - <a asp-area="" asp-page="/Privacy">Privacy</a>
</div>
</footer>

Expand Down
Loading