From 1de3e77d3228c42718aac991c152619f1f85bcd3 Mon Sep 17 00:00:00 2001 From: Emmanuel Knafo Date: Wed, 22 Jul 2026 14:13:29 -0400 Subject: [PATCH] feat(cicd): stamp semantic version into image, footer, image tag and git tag 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 image tag, and created as a v git tag across all three providers. Closes #68 --- .azuredevops/pipelines/cicd.yml | 36 +++++++- .github/workflows/cicd.yml | 50 +++++++++-- .gitlab/ci/deploy.yml | 53 +++++++++++- scripts/version/Get-BuildVersion.ps1 | 104 +++++++++++++++++++++++ src/webapp01/Dockerfile | 4 + src/webapp01/Pages/Shared/_Layout.cshtml | 5 +- 6 files changed, 240 insertions(+), 12 deletions(-) create mode 100644 scripts/version/Get-BuildVersion.ps1 diff --git a/.azuredevops/pipelines/cicd.yml b/.azuredevops/pipelines/cicd.yml index 0e9f0dd..d115d2c 100644 --- a/.azuredevops/pipelines/cicd.yml +++ b/.azuredevops/pipelines/cicd.yml @@ -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: @@ -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.' } @@ -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 @@ -340,6 +350,7 @@ stages: steps: - checkout: self clean: true + persistCredentials: true - task: DownloadPipelineArtifact@2 displayName: Download signed image evidence inputs: @@ -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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 12809e6..a18e9f9 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -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 @@ -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 @@ -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: | @@ -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 @@ -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" diff --git a/.gitlab/ci/deploy.yml b/.gitlab/ci/deploy.yml index 77b2773..378998f 100644 --- a/.gitlab/ci/deploy.yml +++ b/.gitlab/ci/deploy.yml @@ -82,6 +82,8 @@ 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 @@ -89,10 +91,23 @@ cicd:build-and-push: - 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: @@ -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" \ No newline at end of file + - 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." \ No newline at end of file diff --git a/scripts/version/Get-BuildVersion.ps1 b/scripts/version/Get-BuildVersion.ps1 new file mode 100644 index 0000000..7ae4116 --- /dev/null +++ b/scripts/version/Get-BuildVersion.ps1 @@ -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 diff --git a/src/webapp01/Dockerfile b/src/webapp01/Dockerfile index 809601c..38d1386 100644 --- a/src/webapp01/Dockerfile +++ b/src/webapp01/Dockerfile @@ -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"] \ No newline at end of file diff --git a/src/webapp01/Pages/Shared/_Layout.cshtml b/src/webapp01/Pages/Shared/_Layout.cshtml index 89ec8a8..f8391fd 100644 --- a/src/webapp01/Pages/Shared/_Layout.cshtml +++ b/src/webapp01/Pages/Shared/_Layout.cshtml @@ -1,4 +1,5 @@ - +@inject Microsoft.Extensions.Configuration.IConfiguration Configuration + @@ -44,7 +45,7 @@