Skip to content

Commit 0e10a72

Browse files
feat(cicd): stamp semantic version into image, footer, image tag and git tag (#69)
Adds shared scripts/version/Get-BuildVersion.ps1 (1.0.0 + patch per tagged commit) consumed by GitHub and Azure DevOps; GitLab computes the identical scheme inline. The version is passed as the APP_VERSION Docker build-arg, rendered in the web app footer, applied as a v<version> image tag, and created as a v<version> git tag across all three providers. Closes #68
1 parent 958fb81 commit 0e10a72

6 files changed

Lines changed: 240 additions & 12 deletions

File tree

.azuredevops/pipelines/cicd.yml

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ stages:
156156
steps:
157157
- checkout: self
158158
clean: true
159+
fetchDepth: 0
160+
fetchTags: true
159161
- task: UseDotNet@2
160162
displayName: Use .NET SDK 9.0.x
161163
inputs:
@@ -217,15 +219,22 @@ stages:
217219
$acrLoginServer = '$(acrLoginServer)'
218220
if ([string]::IsNullOrWhiteSpace($acrLoginServer)) { throw 'The suffixed ACR login-server output is absent.' }
219221
$acrName = $acrLoginServer.Split('.')[0]
222+
$version = & ./scripts/version/Get-BuildVersion.ps1
223+
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($version)) { throw 'Semantic version computation failed.' }
224+
Write-Host "##vso[task.setvariable variable=buildVersion;isOutput=true]$version"
225+
Write-Host "Computed semantic version: $version"
220226
$commitTag = "$acrLoginServer/$(imageRepository):$(Build.SourceVersion)"
221227
$latestTag = "$acrLoginServer/$(imageRepository):latest"
228+
$versionTag = "$acrLoginServer/$(imageRepository):v$version"
222229
223230
az acr login --name $acrName
224231
if ($LASTEXITCODE -ne 0) { throw 'Workload-identity ACR login failed.' }
225-
docker build src/webapp01 --file src/webapp01/Dockerfile --tag $commitTag --tag $latestTag
232+
docker build src/webapp01 --file src/webapp01/Dockerfile --build-arg APP_VERSION=$version --tag $commitTag --tag $versionTag --tag $latestTag
226233
if ($LASTEXITCODE -ne 0) { throw 'Container image build failed.' }
227234
docker push $commitTag
228235
if ($LASTEXITCODE -ne 0) { throw 'Commit image push failed.' }
236+
docker push $versionTag
237+
if ($LASTEXITCODE -ne 0) { throw 'Version image push failed.' }
229238
docker push $latestTag
230239
if ($LASTEXITCODE -ne 0) { throw 'Convenience image push failed.' }
231240
@@ -330,6 +339,7 @@ stages:
330339
acrLoginServer: $[ stageDependencies.Provision.Deploy.outputs['deployInfrastructure.acrLoginServer'] ]
331340
imageReference: $[ stageDependencies.BuildEvidence.BuildSignVerify.outputs['createEvidence.imageReference'] ]
332341
sbomDigest: $[ stageDependencies.BuildEvidence.BuildSignVerify.outputs['createEvidence.sbomDigest'] ]
342+
buildVersion: $[ stageDependencies.BuildEvidence.BuildSignVerify.outputs['createEvidence.buildVersion'] ]
333343
jobs:
334344
- deployment: DeployWebApp
335345
displayName: Deploy Web App by digest
@@ -340,6 +350,7 @@ stages:
340350
steps:
341351
- checkout: self
342352
clean: true
353+
persistCredentials: true
343354
- task: DownloadPipelineArtifact@2
344355
displayName: Download signed image evidence
345356
inputs:
@@ -416,4 +427,25 @@ stages:
416427
Write-Host "Deployed immutable image: $(imageReference)"
417428
Write-Host "Stable Web App endpoint: $(webAppUrl)"
418429
Write-Host "Signed evidence artifact: signed-image-evidence"
419-
displayName: Emit deployment summary
430+
displayName: Emit deployment summary
431+
- pwsh: |
432+
$ErrorActionPreference = 'Stop'
433+
$version = '$(buildVersion)'
434+
if ([string]::IsNullOrWhiteSpace($version)) { throw 'Semantic version output is absent.' }
435+
$tag = "v$version"
436+
$existing = git ls-remote --tags origin "refs/tags/$tag"
437+
if (-not [string]::IsNullOrWhiteSpace($existing)) {
438+
Write-Host "Tag $tag already exists on origin; skipping."
439+
return
440+
}
441+
git config user.email 'build-service@devopsabcs.com'
442+
git config user.name 'Azure DevOps Build Service'
443+
git tag -a $tag -m "Release $tag"
444+
git push origin $tag
445+
if ($LASTEXITCODE -ne 0) {
446+
Write-Host "##vso[task.logissue type=warning]Unable to push git tag $tag. Grant the build service 'Contribute' permission to enable version tagging."
447+
}
448+
else {
449+
Write-Host "Created and pushed git tag $tag."
450+
}
451+
displayName: Create and push semantic version git tag

.github/workflows/cicd.yml

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,24 @@ env:
4141
DOTNET_VERSION: "9.0.x" # set this to the dot net version to use
4242

4343
jobs:
44+
version:
45+
name: Compute semantic version
46+
runs-on: ubuntu-latest
47+
outputs:
48+
version: ${{ steps.compute.outputs.version }}
49+
steps:
50+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
51+
with:
52+
fetch-depth: 0 # full history + tags required for deterministic version computation
53+
54+
- name: Compute build version
55+
id: compute
56+
shell: pwsh
57+
run: |
58+
$version = & ./scripts/version/Get-BuildVersion.ps1
59+
Write-Host "Computed semantic version: $version"
60+
echo "version=$version" >> $env:GITHUB_OUTPUT
61+
4462
deploy-infrastructure:
4563
name: Deploy Azure Infrastructure
4664
runs-on: ubuntu-latest
@@ -149,11 +167,12 @@ jobs:
149167

150168
cicd:
151169
name: Build and Deploy to Azure Web App
152-
needs: deploy-infrastructure
170+
needs: [version, deploy-infrastructure]
153171
runs-on: ubuntu-latest
154172
env:
155173
AZURE_ACR_NAME: ${{ needs.deploy-infrastructure.outputs.acr_name }}
156174
AZURE_WEBAPP_NAME: ${{ needs.deploy-infrastructure.outputs.webapp_name }}
175+
APP_VERSION: ${{ needs.version.outputs.version }}
157176
steps:
158177
# Checkout the repo
159178
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -191,16 +210,33 @@ jobs:
191210

192211
- name: Build and Push Docker Image
193212
run: |
194-
docker build ./src/webapp01 --file ./src/webapp01/Dockerfile -t ${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:${{ github.sha }}
195-
docker tag ${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:${{ github.sha }} ${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:latest
213+
docker build ./src/webapp01 --file ./src/webapp01/Dockerfile \
214+
--build-arg APP_VERSION=${{ env.APP_VERSION }} \
215+
-t ${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:${{ github.sha }} \
216+
-t ${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:v${{ env.APP_VERSION }} \
217+
-t ${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:latest
196218
docker push ${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:${{ github.sha }}
219+
docker push ${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:v${{ env.APP_VERSION }}
197220
docker push ${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:latest
198221
199222
- name: Azure Web Apps Deploy
200223
uses: azure/webapps-deploy@8db8b8d14f21b245e6706fd0607244e354884697 # v3
201224
with:
202225
app-name: ${{ env.AZURE_WEBAPP_NAME }}
203-
images: "${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:${{ github.sha }}"
226+
images: "${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:v${{ env.APP_VERSION }}"
227+
228+
- name: Create and push semantic version git tag
229+
shell: pwsh
230+
run: |
231+
$tag = "v${{ env.APP_VERSION }}"
232+
$remote = & git ls-remote --tags origin "refs/tags/$tag"
233+
if (-not [string]::IsNullOrWhiteSpace($remote)) {
234+
Write-Host "Tag $tag already exists on origin; skipping."
235+
exit 0
236+
}
237+
& git tag -a $tag -m "Release $tag"
238+
& git push origin $tag
239+
Write-Host "Created and pushed git tag $tag."
204240
205241
- name: Add deployment link to summary
206242
run: |
@@ -211,7 +247,8 @@ jobs:
211247
echo "| Resource | Value |" >> $GITHUB_STEP_SUMMARY
212248
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
213249
echo "| URL | ${{ needs.deploy-infrastructure.outputs.webapp_url }} |" >> $GITHUB_STEP_SUMMARY
214-
echo "| Image | \`${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:${{ github.sha }}\` |" >> $GITHUB_STEP_SUMMARY
250+
echo "| Version | \`v${{ env.APP_VERSION }}\` |" >> $GITHUB_STEP_SUMMARY
251+
echo "| Image | \`${{ env.AZURE_ACR_NAME }}.azurecr.io/webapp01:v${{ env.APP_VERSION }}\` |" >> $GITHUB_STEP_SUMMARY
215252
echo "| Commit | \`${{ github.sha }}\` |" >> $GITHUB_STEP_SUMMARY
216253
217254
- name: logout
@@ -221,10 +258,11 @@ jobs:
221258
# 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
222259
container-build-publish:
223260
name: Build and Publish Container Image
261+
needs: version
224262
uses: devopsabcs-engineering/devsecops-reusable-workflows/.github/workflows/container.yml@main
225263
with:
226264
# This is used for tagging the container image
227-
version: v1.0.0
265+
version: v${{ needs.version.outputs.version }}
228266
container-file: ./src/webapp01/Dockerfile
229267
container-context: ./src/webapp01
230268
container-name: "${{ github.repository }}/webapp01"

.gitlab/ci/deploy.yml

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,17 +82,32 @@ cicd:build-and-push:
8282
needs:
8383
- job: cicd:deploy-infrastructure
8484
artifacts: true
85+
variables:
86+
GIT_DEPTH: 0 # full history + tags for deterministic version computation
8587
id_tokens:
8688
AZURE_OIDC_TOKEN:
8789
aud: api://AzureADTokenExchange
8890
script:
8991
- az login --service-principal --username "$AZURE_CLIENT_ID" --tenant "$AZURE_TENANT_ID" --federated-token "$AZURE_OIDC_TOKEN" --allow-no-subscriptions
9092
- az account set --subscription "$AZURE_SUBSCRIPTION_ID"
9193
- ACR_NAME="${ACR_LOGIN_SERVER%%.*}"
92-
- az acr build --registry "$ACR_NAME" --image "$IMAGE_NAME:$CI_COMMIT_SHA" --target final --file src/webapp01/Dockerfile src/webapp01
94+
# Semantic version, mirroring scripts/version/Get-BuildVersion.ps1 (1.0.0, patch++ per tagged
95+
# commit). Computed inline because the fleet runner image provides git but not PowerShell.
96+
- |
97+
BUILD_VERSION="1.0.0"
98+
if command -v git >/dev/null 2>&1; then
99+
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)
100+
HIGHEST=$(git tag --list 'v1.0.*' 2>/dev/null | sed -n 's/^v1\.0\.\([0-9][0-9]*\)$/\1/p' | sort -n | tail -1)
101+
if [ -n "$ON_HEAD" ]; then BUILD_VERSION="1.0.$ON_HEAD";
102+
elif [ -n "$HIGHEST" ]; then BUILD_VERSION="1.0.$((HIGHEST + 1))";
103+
else BUILD_VERSION="1.0.0"; fi
104+
fi
105+
echo "Computed semantic version: $BUILD_VERSION"
106+
- 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
93107
- IMAGE_DIGEST="$(az acr repository show --name "$ACR_NAME" --image "$IMAGE_NAME:$CI_COMMIT_SHA" --query digest --output tsv)"
94108
- test -n "$IMAGE_DIGEST"
95109
- echo "IMAGE_REFERENCE=$ACR_LOGIN_SERVER/$IMAGE_NAME@$IMAGE_DIGEST" > image.env
110+
- echo "BUILD_VERSION=$BUILD_VERSION" >> image.env
96111
artifacts:
97112
expire_in: 30 days
98113
reports:
@@ -168,4 +183,38 @@ cicd:deploy-webapp:
168183
- az login --service-principal --username "$AZURE_CLIENT_ID" --tenant "$AZURE_TENANT_ID" --federated-token "$AZURE_OIDC_TOKEN" --allow-no-subscriptions
169184
- az account set --subscription "$AZURE_SUBSCRIPTION_ID"
170185
- az webapp config container set --resource-group "$RESOURCE_GROUP_NAME" --name "$WEB_APP_NAME" --docker-custom-image-name "$IMAGE_REFERENCE"
171-
- curl --fail --retry 12 --retry-delay 10 --retry-all-errors "$WEB_APP_URL"
186+
- curl --fail --retry 12 --retry-delay 10 --retry-all-errors "$WEB_APP_URL"
187+
188+
# Creates the semantic version git tag in source control (parity with GitHub/ADO).
189+
# Non-fatal: requires a GITLAB_TAG_TOKEN (project access token with write_repository).
190+
release:tag:
191+
extends:
192+
- .rules:main-and-manual
193+
stage: deploy
194+
image: mcr.microsoft.com/azure-cli:2.71.0
195+
needs:
196+
- job: cicd:build-and-push
197+
artifacts: true
198+
- job: cicd:deploy-webapp
199+
variables:
200+
GIT_DEPTH: 0
201+
allow_failure: true
202+
script:
203+
- test -n "$BUILD_VERSION"
204+
- git config --global --add safe.directory "$CI_PROJECT_DIR"
205+
- |
206+
if git ls-remote --tags origin "refs/tags/v$BUILD_VERSION" | grep -q "v$BUILD_VERSION"; then
207+
echo "Tag v$BUILD_VERSION already exists on origin; skipping."
208+
exit 0
209+
fi
210+
- |
211+
if [ -z "$GITLAB_TAG_TOKEN" ]; then
212+
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."
213+
exit 0
214+
fi
215+
- git config user.email "gitlab-ci@devopsabcs.com"
216+
- git config user.name "GitLab CI"
217+
- git remote set-url origin "https://oauth2:${GITLAB_TAG_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
218+
- git tag -a "v$BUILD_VERSION" -m "Release v$BUILD_VERSION"
219+
- git push origin "v$BUILD_VERSION"
220+
- echo "Created and pushed git tag v$BUILD_VERSION."
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
#Requires -Version 7.0
2+
<#
3+
.SYNOPSIS
4+
Computes the deterministic semantic build version shared by all three CI/CD providers
5+
(GitHub Actions, Azure DevOps, GitLab CI).
6+
7+
.DESCRIPTION
8+
The version scheme is MAJOR.MINOR.PATCH starting at 1.0.0. The patch component is derived
9+
from the highest existing 'vMAJOR.MINOR.*' git tag plus one, so every tagged pipeline run on
10+
the default branch increments the patch by exactly one:
11+
12+
(no tags) -> 1.0.0
13+
v1.0.0 exists -> 1.0.1
14+
v1.0.1 exists -> 1.0.2 ...
15+
16+
If the current commit (HEAD) is already tagged with a matching version, that version is reused
17+
so pipeline re-runs are idempotent and never produce a duplicate or skipped patch.
18+
19+
The computation depends only on git tags, so it produces identical results on every provider
20+
without any external tooling. Pipelines must fetch the full history and tags (for example
21+
'fetch-depth: 0' on GitHub, 'fetch: 0' / unshallow on Azure DevOps, and 'GIT_DEPTH: 0' on
22+
GitLab) so the tag list is complete.
23+
24+
.PARAMETER Major
25+
Major version component. Defaults to 1.
26+
27+
.PARAMETER Minor
28+
Minor version component. Defaults to 0.
29+
30+
.PARAMETER CreateTag
31+
When set, creates the annotated tag 'vMAJOR.MINOR.PATCH' locally (if it does not already
32+
exist). Pushing the tag is left to the caller so provider-specific credentials are used.
33+
34+
.OUTPUTS
35+
System.String. The computed semantic version (for example '1.0.3').
36+
#>
37+
[CmdletBinding()]
38+
param(
39+
[int]$Major = 1,
40+
[int]$Minor = 0,
41+
[switch]$CreateTag
42+
)
43+
44+
Set-StrictMode -Version Latest
45+
$ErrorActionPreference = 'Stop'
46+
47+
function Get-NextBuildVersion {
48+
param(
49+
[int]$Major,
50+
[int]$Minor
51+
)
52+
53+
$prefix = "v$Major.$Minor."
54+
$head = (& git rev-parse HEAD 2>$null)
55+
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($head)) {
56+
throw 'Unable to resolve HEAD commit. Run this script inside a git working tree with full history.'
57+
}
58+
$head = $head.Trim()
59+
60+
# Reuse a version already tagged on HEAD to keep pipeline re-runs idempotent.
61+
$onHead = @(& git tag --points-at $head --list "$prefix*")
62+
$reusePatches = @(
63+
$onHead |
64+
ForEach-Object { $_.Substring($prefix.Length) } |
65+
Where-Object { $_ -match '^\d+$' } |
66+
ForEach-Object { [int]$_ }
67+
)
68+
if ($reusePatches.Count -gt 0) {
69+
$patch = ($reusePatches | Measure-Object -Maximum).Maximum
70+
return "$Major.$Minor.$patch"
71+
}
72+
73+
# Otherwise take the highest existing patch for this MAJOR.MINOR and increment it.
74+
$allTags = @(& git tag --list "$prefix*")
75+
$patches = @(
76+
$allTags |
77+
ForEach-Object { $_.Substring($prefix.Length) } |
78+
Where-Object { $_ -match '^\d+$' } |
79+
ForEach-Object { [int]$_ }
80+
)
81+
if ($patches.Count -gt 0) {
82+
$next = ($patches | Measure-Object -Maximum).Maximum + 1
83+
}
84+
else {
85+
$next = 0
86+
}
87+
88+
return "$Major.$Minor.$next"
89+
}
90+
91+
$version = Get-NextBuildVersion -Major $Major -Minor $Minor
92+
93+
if ($CreateTag) {
94+
$tag = "v$version"
95+
$existing = @(& git tag --list $tag)
96+
if ($existing.Count -eq 0) {
97+
& git tag -a $tag -m "Release $tag" | Out-Null
98+
if ($LASTEXITCODE -ne 0) {
99+
throw "Failed to create git tag $tag."
100+
}
101+
}
102+
}
103+
104+
Write-Output $version

src/webapp01/Dockerfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ RUN dotnet publish "./webapp01.csproj" -c $BUILD_CONFIGURATION -o /app/publish /
2424

2525
# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration)
2626
FROM base AS final
27+
# Semantic build version stamped by the CI/CD pipelines (GitHub, Azure DevOps, GitLab).
28+
# Defaults to 0.0.0 so source-only builds (e.g. container security scans) still succeed.
29+
ARG APP_VERSION=0.0.0
30+
ENV APP_VERSION=$APP_VERSION
2731
WORKDIR /app
2832
COPY --from=publish /app/publish .
2933
ENTRYPOINT ["dotnet", "webapp01.dll"]

src/webapp01/Pages/Shared/_Layout.cshtml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
<!DOCTYPE html>
1+
@inject Microsoft.Extensions.Configuration.IConfiguration Configuration
2+
<!DOCTYPE html>
23
<html lang="en">
34
<head>
45
<meta charset="utf-8" />
@@ -44,7 +45,7 @@
4445

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

0 commit comments

Comments
 (0)