From 167cf79692c129fc6cf38dc4e484bed527a961ee Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 30 Apr 2026 12:16:59 +0100 Subject: [PATCH 1/5] azure-pipelines: flesh out release pipeline infrastructure The stub release pipeline added in 4b88e15047 had placeholder steps and was missing several pieces needed for real builds. Flesh it out: - Add poolArch to the Windows and Linux matrix entries so we can set hostArchitecture on the pool, which is required for the arm64 hosted agents to get the correct image. - Convert macOS from a single hardcoded job to a matrix parameter, matching the pattern used by Windows and Linux. This makes it easy to add additional macOS configurations in the future. - Add a resolve-version.sh script that derives the Git version, tag name, and tag SHA from the repository state. The prereqs stage currently inlines a static placeholder; the script is provided for when we are ready to switch. - Add a setup-git-bash.cmd script that prepends Git Bash to the PATH on Windows agents, so that subsequent Bash tasks can find the shell. - Add checkout with fetchDepth: 0 and fetchTags: true to the prereqs stage so the version resolution script can inspect tags. - Add macOS validation jobs to the release stage, and wire up macOS artifacts in the GitHub release publishing job. Signed-off-by: Matthew John Cheetham --- .azure-pipelines/release.yml | 218 +++++++++++++----- .azure-pipelines/scripts/resolve-version.sh | 48 ++++ .../scripts/windows/setup-git-bash.cmd | 13 ++ 3 files changed, 224 insertions(+), 55 deletions(-) create mode 100755 .azure-pipelines/scripts/resolve-version.sh create mode 100644 .azure-pipelines/scripts/windows/setup-git-bash.cmd diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 0e0ce01ac71978..bd646d2bd40b01 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -26,6 +26,7 @@ parameters: - id: windows_x64 jobName: 'Windows (x64)' pool: GitClientPME-1ESHostedPool-intel-pc + poolArch: amd64 image: win-x86_64-ado1es os: windows toolchain: x86_64 @@ -34,13 +35,20 @@ parameters: - id: windows_arm64 jobName: 'Windows (ARM64)' pool: GitClientPME-1ESHostedPool-arm64-pc + poolArch: arm64 image: win-arm64-ado1es os: windows toolchain: clang-aarch64 mingwprefix: clangarm64 - # No matrix for macOS as we build both x64 and ARM64 in the same job - # and produce a universal binary. + - name: macos_matrix + type: object + default: + - id: macos_universal + jobName: 'macOS (x64 + ARM64)' + pool: 'Azure Pipelines' + image: macOS-latest + os: macos - name: linux_matrix type: object @@ -48,6 +56,7 @@ parameters: - id: linux_x64 jobName: 'Linux (x64)' pool: GitClientPME-1ESHostedPool-intel-pc + poolArch: amd64 image: ubuntu-x86_64-ado1es os: linux cc_arch: x86_64 @@ -56,6 +65,7 @@ parameters: - id: linux_arm64 jobName: 'Linux (ARM64)' pool: GitClientPME-1ESHostedPool-arm64-pc + poolArch: arm64 image: ubuntu-arm64-ado1es os: linux cc_arch: aarch64 @@ -85,20 +95,15 @@ extends: image: ubuntu-x86_64-ado1es os: linux steps: + - checkout: self + fetchDepth: 0 + fetchTags: true - task: Bash@3 displayName: 'Resolve version and tag information' name: info inputs: - targetType: inline - script: | - # TODO: determine git_version, tag_name, and tag_sha - # TODO: error if the current commit is not an annotated tag - git_version=TODO_GITVER - tag_name=TODO_TAGNAME - tag_sha=TODO_TAGSHA - echo "##vso[task.setvariable variable=git_version;isOutput=true;isReadOnly=true]$git_version" - echo "##vso[task.setvariable variable=tag_name;isOutput=true;isReadOnly=true]$tag_name" - echo "##vso[task.setvariable variable=tag_sha;isOutput=true;isReadOnly=true]$tag_sha" + targetType: filePath + filePath: .azure-pipelines/scripts/resolve-version.sh - stage: build displayName: 'Build' @@ -114,6 +119,7 @@ extends: name: ${{ dim.pool }} image: ${{ dim.image }} os: ${{ dim.os }} + hostArchitecture: ${{ dim.poolArch }} variables: tag_name: $[stageDependencies.prereqs.prebuild.outputs['info.tag_name']] tag_sha: $[stageDependencies.prereqs.prebuild.outputs['info.tag_sha']] @@ -127,44 +133,60 @@ extends: artifactName: '${{ dim.id }}' steps: - checkout: self + # Add Git Bash to the PATH so Bash tasks can find it + - task: BatchScript@1 + displayName: 'Add Git Bash to PATH' + inputs: + filename: ./.azure-pipelines/scripts/windows/setup-git-bash.cmd # TODO: add tasks to set up Git for Windows SDK # TODO: add tasks to build Git and installers - script: | echo $(mingwprefix) echo $(toolchain) + mkdir $(Build.ArtifactStagingDirectory)\app + copy C:\Windows\System32\calc.exe $(Build.ArtifactStagingDirectory)\app\example1.exe + copy C:\Windows\System32\calc.exe $(Build.ArtifactStagingDirectory)\app\example2.exe + copy C:\Windows\System32\calc.exe $(Build.ArtifactStagingDirectory)\app\example3.exe displayName: 'Dummy build' # TODO: put final artifacts under $(Build.ArtifactStagingDirectory)/_final - script: | - echo "TODO" > $(Build.ArtifactStagingDirectory)/_final/placeholder.txt + mkdir $(Build.ArtifactStagingDirectory)\_final + xcopy /s /y $(Build.ArtifactStagingDirectory)\app $(Build.ArtifactStagingDirectory)\_final + displayName: 'Dummy collect artifacts' # - # macOS build job (universal) + # macOS build jobs # - - job: macos_universal - displayName: 'macOS (x64 + ARM64)' - pool: - name: 'Azure Pipelines' - image: macOS-latest - os: macos - variables: - tag_name: $[stageDependencies.prereqs.prebuild.outputs['info.tag_name']] - tag_sha: $[stageDependencies.prereqs.prebuild.outputs['info.tag_sha']] - git_version: $[stageDependencies.prereqs.prebuild.outputs['info.git_version']] - templateContext: - outputs: - - output: pipelineArtifact - targetPath: '$(Build.ArtifactStagingDirectory)/_final' - artifactName: 'macos_universal' - steps: - - checkout: self - # TODO: add tasks to set up build environment - # TODO: add tasks to build Git and installers - - script: | - echo "Hello, Mac!" - displayName: 'Dummy build' - # TODO: put final artifacts under $(Build.ArtifactStagingDirectory)/_final - - script: | - echo "TODO" > $(Build.ArtifactStagingDirectory)/_final/placeholder.txt + - ${{ each dim in parameters.macos_matrix }}: + - job: ${{ dim.id }} + displayName: ${{ dim.jobName }} + pool: + name: ${{ dim.pool }} + image: ${{ dim.image }} + os: ${{ dim.os }} + variables: + tag_name: $[stageDependencies.prereqs.prebuild.outputs['info.tag_name']] + tag_sha: $[stageDependencies.prereqs.prebuild.outputs['info.tag_sha']] + git_version: $[stageDependencies.prereqs.prebuild.outputs['info.git_version']] + templateContext: + outputs: + - output: pipelineArtifact + targetPath: '$(Build.ArtifactStagingDirectory)/_final' + artifactName: '${{ dim.id }}' + steps: + - checkout: self + # TODO: add tasks to set up build environment + # TODO: add tasks to build Git and installers + - script: | + echo "Hello, Mac!" + mkdir -p $(Build.ArtifactStagingDirectory)/app + cp /bin/echo $(Build.ArtifactStagingDirectory)/app/example + displayName: 'Dummy build' + # TODO: put final artifacts under $(Build.ArtifactStagingDirectory)/_final + - script: | + mkdir -p $(Build.ArtifactStagingDirectory)/_final + cp -R $(Build.ArtifactStagingDirectory)/app/* $(Build.ArtifactStagingDirectory)/_final/ + displayName: 'Dummy collect artifacts' # # Linux build jobs @@ -176,6 +198,7 @@ extends: name: ${{ dim.pool }} image: ${{ dim.image }} os: ${{ dim.os }} + hostArchitecture: ${{ dim.poolArch }} variables: tag_name: $[stageDependencies.prereqs.prebuild.outputs['info.tag_name']] tag_sha: $[stageDependencies.prereqs.prebuild.outputs['info.tag_sha']] @@ -194,16 +217,104 @@ extends: - script: | echo $(cc_arch) echo $(deb_arch) + mkdir -p $(Build.ArtifactStagingDirectory)/app + debroot=$(Build.ArtifactStagingDirectory)/pkgroot + mkdir -p $debroot/DEBIAN + cat > $debroot/DEBIAN/control < $(Build.ArtifactStagingDirectory)/_final/placeholder.txt + mkdir -p $(Build.ArtifactStagingDirectory)/_final + cp -R $(Build.ArtifactStagingDirectory)/app/* $(Build.ArtifactStagingDirectory)/_final/ + displayName: 'Dummy collect artifacts' - stage: release displayName: 'Release' dependsOn: [prereqs, build] jobs: + # + # Windows validation jobs + # + - ${{ each dim in parameters.windows_matrix }}: + - job: validate_${{ dim.id }} + displayName: 'Validate ${{ dim.jobName }}' + pool: + name: ${{ dim.pool }} + image: ${{ dim.image }} + os: ${{ dim.os }} + hostArchitecture: ${{ dim.poolArch }} + templateContext: + inputs: + - input: pipelineArtifact + artifactName: '${{ dim.id }}' + targetPath: $(Pipeline.Workspace)/assets/${{ dim.id }} + steps: + # TODO: add artifact validation steps + - script: | + dir $(Pipeline.Workspace)\assets\${{ dim.id }} + displayName: 'Validate artifacts' + + # + # macOS validation jobs + # + - ${{ each dim in parameters.macos_matrix }}: + - job: validate_${{ dim.id }} + displayName: 'Validate ${{ dim.jobName }}' + pool: + name: ${{ dim.pool }} + image: ${{ dim.image }} + os: ${{ dim.os }} + templateContext: + inputs: + - input: pipelineArtifact + artifactName: '${{ dim.id }}' + targetPath: $(Pipeline.Workspace)/assets/${{ dim.id }} + steps: + # TODO: add artifact validation steps + - script: | + ls $(Pipeline.Workspace)/assets/${{ dim.id }} + displayName: 'Validate artifacts' + + # + # Linux validation jobs + # + - ${{ each dim in parameters.linux_matrix }}: + - job: validate_${{ dim.id }} + displayName: 'Validate ${{ dim.jobName }}' + pool: + name: ${{ dim.pool }} + image: ${{ dim.image }} + os: ${{ dim.os }} + hostArchitecture: ${{ dim.poolArch }} + templateContext: + inputs: + - input: pipelineArtifact + artifactName: '${{ dim.id }}' + targetPath: $(Pipeline.Workspace)/assets/${{ dim.id }} + steps: + # TODO: add artifact validation steps + - script: | + ls $(Pipeline.Workspace)/assets/${{ dim.id }} + displayName: 'Validate artifacts' + + # + # GitHub release publishing + # - job: github + dependsOn: + - ${{ each dim in parameters.windows_matrix }}: + - validate_${{ dim.id }} + - ${{ each dim in parameters.macos_matrix }}: + - validate_${{ dim.id }} + - ${{ each dim in parameters.linux_matrix }}: + - validate_${{ dim.id }} displayName: 'Publish GitHub release' condition: and(succeeded(), eq('${{ parameters.github }}', true)) pool: @@ -218,21 +329,18 @@ extends: type: releaseJob isProduction: true inputs: - - input: pipelineArtifact - artifactName: 'windows_x64' - targetPath: $(Pipeline.Workspace)/assets/windows_x64 - - input: pipelineArtifact - artifactName: 'windows_arm64' - targetPath: $(Pipeline.Workspace)/assets/windows_arm64 - - input: pipelineArtifact - artifactName: 'macos_universal' - targetPath: $(Pipeline.Workspace)/assets/macos_universal - - input: pipelineArtifact - artifactName: 'linux_x64' - targetPath: $(Pipeline.Workspace)/assets/linux_x64 - - input: pipelineArtifact - artifactName: 'linux_arm64' - targetPath: $(Pipeline.Workspace)/assets/linux_arm64 + - ${{ each dim in parameters.windows_matrix }}: + - input: pipelineArtifact + artifactName: '${{ dim.id }}' + targetPath: $(Pipeline.Workspace)/assets/${{ dim.id }} + - ${{ each dim in parameters.macos_matrix }}: + - input: pipelineArtifact + artifactName: '${{ dim.id }}' + targetPath: $(Pipeline.Workspace)/assets/${{ dim.id }} + - ${{ each dim in parameters.linux_matrix }}: + - input: pipelineArtifact + artifactName: '${{ dim.id }}' + targetPath: $(Pipeline.Workspace)/assets/${{ dim.id }} steps: - task: GitHubRelease@1 displayName: 'Create Draft GitHub Release' diff --git a/.azure-pipelines/scripts/resolve-version.sh b/.azure-pipelines/scripts/resolve-version.sh new file mode 100755 index 00000000000000..6169ae767bae1f --- /dev/null +++ b/.azure-pipelines/scripts/resolve-version.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# +# Resolve version and tag information from the current HEAD commit. +# Validates that HEAD is an annotated version tag matching GIT-VERSION-GEN. +# +# Sets the following ADO output variables (via ##vso): +# git_version - Version string without "v" prefix (e.g., 2.53.0.vfs.0.0) +# tag_name - Full tag name (e.g., v2.53.0.vfs.0.0) +# tag_sha - Commit SHA of HEAD +# +# Also updates the build number to include the tag name. +# +set -euo pipefail + +echo "HEAD: $(git rev-parse HEAD)" + +# Determine the tag pointing at HEAD +tag_name=$(git describe --exact-match --match "v[0-9]*vfs*" HEAD 2>/dev/null) || { + echo "##vso[task.logissue type=error]HEAD is not tagged with a version tag" + exit 1 +} + +# Verify the tag is annotated (not lightweight) +tag_type=$(git cat-file -t "refs/tags/$tag_name") +if [ "$tag_type" != "tag" ]; then + echo "##vso[task.logissue type=error]Tag $tag_name is not annotated (type: $tag_type)" + exit 1 +fi + +tag_sha=$(git rev-parse HEAD) +git_version="${tag_name#v}" + +# Verify the version matches GIT-VERSION-GEN +make GIT-VERSION-FILE +expected_version="${git_version//-rc/.rc}" +actual_version=$(sed -n 's/^GIT_VERSION *= *//p' < GIT-VERSION-FILE) +if [ "$expected_version" != "$actual_version" ]; then + echo "##vso[task.logissue type=error]GIT-VERSION-FILE ($actual_version) does not match tag $tag_name ($expected_version)" + exit 1 +fi + +echo "Git version: $git_version" +echo "Tag name: $tag_name" +echo "Tag SHA: $tag_sha" +echo "##vso[task.setvariable variable=git_version;isOutput=true;isReadOnly=true]$git_version" +echo "##vso[task.setvariable variable=tag_name;isOutput=true;isReadOnly=true]$tag_name" +echo "##vso[task.setvariable variable=tag_sha;isOutput=true;isReadOnly=true]$tag_sha" +echo "##vso[build.updatebuildnumber]${tag_name} (${BUILD_BUILDNUMBER:-unknown})" diff --git a/.azure-pipelines/scripts/windows/setup-git-bash.cmd b/.azure-pipelines/scripts/windows/setup-git-bash.cmd new file mode 100644 index 00000000000000..b3ef5518cfc85d --- /dev/null +++ b/.azure-pipelines/scripts/windows/setup-git-bash.cmd @@ -0,0 +1,13 @@ +@echo off +setlocal enabledelayedexpansion +set "agentgit=%AGENT_HOMEDIRECTORY%\externals\git" +set "gitcopy=%AGENT_TEMPDIRECTORY%\git" +echo Copying !agentgit! to !gitcopy!... +xcopy /E /I /Q "!agentgit!" "!gitcopy!" +if not exist "!gitcopy!\usr\bin\sh.exe" ( + echo ##vso[task.logissue type=error]Could not find sh.exe at !gitcopy!\usr\bin\sh.exe + exit /b 1 +) +echo Copying !gitcopy!\usr\bin\sh.exe to !gitcopy!\usr\bin\bash.exe... +copy /Y "!gitcopy!\usr\bin\sh.exe" "!gitcopy!\usr\bin\bash.exe" +echo ##vso[task.prependpath]!gitcopy!\usr\bin From 406d385f6ec0022699b67b662df6587587059734 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 30 Apr 2026 12:27:46 +0100 Subject: [PATCH 2/5] azure-pipelines: add ESRP code signing Add ESRP code signing support to the release pipeline, gated behind an 'esrp' boolean parameter that defaults to false for now. The Windows signing flow uses a custom script (esrpsign.sh) rather than the EsrpCodeSigning ADO task so that we can later integrate signing with the 'git signtool' alias from Git for Windows' build process. The setup template uses AzureCLI@2 to bind to the WIF service connection by name (avoiding hardcoded GUIDs) and derives the service principal ID, tenant ID, and connection GUID at runtime via addSpnToEnvironment and ENDPOINT_URL_* env vars. EsrpClientTool@4 handles downloading and caching the ESRP client binary. For macOS and Linux, we use the EsrpCodeSigning@6 ADO task through a reusable sign.yml template. On macOS, files must be submitted as a zip archive (useArchive: true); the template handles the copy, zip, sign, and extract cycle. New files: - esrp/windows/setup.yml: installs ESRP client and generates the auth JSON needed by ESRPClient.exe - esrp/windows/esrpsign.sh: invokes ESRPClient.exe with Authenticode signing operations - esrp/sign.yml: reusable step template wrapping EsrpCodeSigning@6 with optional archive support for macOS Signed-off-by: Matthew John Cheetham --- .azure-pipelines/esrp/sign.yml | 106 ++++++++++++ .azure-pipelines/esrp/windows/esrpsign.sh | 198 ++++++++++++++++++++++ .azure-pipelines/esrp/windows/setup.yml | 69 ++++++++ .azure-pipelines/release.yml | 75 ++++++++ 4 files changed, 448 insertions(+) create mode 100644 .azure-pipelines/esrp/sign.yml create mode 100755 .azure-pipelines/esrp/windows/esrpsign.sh create mode 100644 .azure-pipelines/esrp/windows/setup.yml diff --git a/.azure-pipelines/esrp/sign.yml b/.azure-pipelines/esrp/sign.yml new file mode 100644 index 00000000000000..b4d14d2713ee8f --- /dev/null +++ b/.azure-pipelines/esrp/sign.yml @@ -0,0 +1,106 @@ +# Reusable step template for ESRP code signing via EsrpCodeSigning@6. +# +# For macOS, ESRP requires files to be submitted as a zip archive. +# Set 'useArchive: true' to automatically handle the +# copy → zip → sign → extract cycle. For Windows/Linux where ESRP +# can sign files directly in a folder, leave it as false (default). +# +parameters: + - name: displayName + type: string + - name: folderPath + type: string + - name: pattern + type: string + - name: inlineOperation + type: string + # When true, matching files are copied to a staging dir, zipped, + # signed, and extracted back to folderPath. + - name: useArchive + type: boolean + default: false + # ESRP connection parameters (defaults use pipeline variables) + - name: connectedServiceName + type: string + default: $(esrpAppConnectionName) + - name: appRegistrationClientId + type: string + default: $(esrpClientId) + - name: appRegistrationTenantId + type: string + default: $(esrpTenantId) + - name: authAkvName + type: string + default: $(esrpKeyVaultName) + - name: authSignCertName + type: string + default: $(esrpSignReqCertName) + - name: serviceEndpointUrl + type: string + default: $(esrpEndpointUrl) + +steps: + - ${{ if eq(parameters.useArchive, true) }}: + - task: DeleteFiles@1 + displayName: 'Clean staging dir for ${{ parameters.displayName }}' + inputs: + SourceFolder: '$(Agent.TempDirectory)/esrp-staging' + Contents: '*' + RemoveSourceFolder: true + - task: CopyFiles@2 + displayName: 'Collect files for ${{ parameters.displayName }}' + inputs: + SourceFolder: '${{ parameters.folderPath }}' + Contents: '${{ parameters.pattern }}' + TargetFolder: '$(Agent.TempDirectory)/esrp-staging/contents' + - task: ArchiveFiles@2 + displayName: 'Archive files for ${{ parameters.displayName }}' + inputs: + rootFolderOrFile: '$(Agent.TempDirectory)/esrp-staging/contents' + includeRootFolder: false + archiveType: zip + archiveFile: '$(Agent.TempDirectory)/esrp-staging/archive.zip' + - task: EsrpCodeSigning@6 + displayName: '${{ parameters.displayName }}' + inputs: + connectedServiceName: '${{ parameters.connectedServiceName }}' + useMSIAuthentication: true + appRegistrationClientId: '${{ parameters.appRegistrationClientId }}' + appRegistrationTenantId: '${{ parameters.appRegistrationTenantId }}' + authAkvName: '${{ parameters.authAkvName }}' + authSignCertName: '${{ parameters.authSignCertName }}' + serviceEndpointUrl: '${{ parameters.serviceEndpointUrl }}' + folderPath: '$(Agent.TempDirectory)/esrp-staging' + pattern: 'archive.zip' + useMinimatch: true + signConfigType: inlineSignParams + inlineOperation: ${{ parameters.inlineOperation }} + - task: ExtractFiles@1 + displayName: 'Extract signed files for ${{ parameters.displayName }}' + inputs: + archiveFilePatterns: '$(Agent.TempDirectory)/esrp-staging/archive.zip' + destinationFolder: '${{ parameters.folderPath }}' + overwriteExistingFiles: true + - task: DeleteFiles@1 + displayName: 'Clean up staging dir for ${{ parameters.displayName }}' + condition: always() + inputs: + SourceFolder: '$(Agent.TempDirectory)/esrp-staging' + Contents: '*' + RemoveSourceFolder: true + - ${{ else }}: + - task: EsrpCodeSigning@6 + displayName: '${{ parameters.displayName }}' + inputs: + connectedServiceName: '${{ parameters.connectedServiceName }}' + useMSIAuthentication: true + appRegistrationClientId: '${{ parameters.appRegistrationClientId }}' + appRegistrationTenantId: '${{ parameters.appRegistrationTenantId }}' + authAkvName: '${{ parameters.authAkvName }}' + authSignCertName: '${{ parameters.authSignCertName }}' + serviceEndpointUrl: '${{ parameters.serviceEndpointUrl }}' + folderPath: '${{ parameters.folderPath }}' + pattern: '${{ parameters.pattern }}' + useMinimatch: true + signConfigType: inlineSignParams + inlineOperation: ${{ parameters.inlineOperation }} diff --git a/.azure-pipelines/esrp/windows/esrpsign.sh b/.azure-pipelines/esrp/windows/esrpsign.sh new file mode 100755 index 00000000000000..a3bf1bc66ea4f8 --- /dev/null +++ b/.azure-pipelines/esrp/windows/esrpsign.sh @@ -0,0 +1,198 @@ +#!/bin/bash +# +# Sign Windows files using the ESRP client (Authenticode). +# Usage: esrpsign.sh [file2 ...] +# +# Required environment variables: +# ESRP_TOOL - Path to ESRPClient.exe +# ESRP_AUTH - Path to the ESRP auth JSON file +# SYSTEM_ACCESSTOKEN - ADO system access token (OAuth bearer) +# +# Optional environment variables: +# ESRP_KEYCODE - Signing key code (default: CP-231522) +# +# The script generates the auth and input JSON files and sets the +# following ESRP client environment variables automatically: +# ESRP_AUTH_CONFIG - Path to the auth JSON file +# ESRP_POLICY_CONFIG - Path to the policy JSON file +# ESRP_SESSION_CONFIG - Not set; ESRP client defaults are used +# +set -euo pipefail + +if [ $# -lt 1 ]; then + echo "usage: esrpsign.sh [file ...]" >&2 + exit 1 +fi + +if [ -z "${ESRP_TOOL:-}" ]; then + echo "error: ESRP_TOOL environment variable must be set" >&2 + exit 1 +fi +if [ -z "${ESRP_AUTH:-}" ]; then + echo "error: ESRP_AUTH environment variable must be set" >&2 + exit 1 +fi +if [ -z "${SYSTEM_ACCESSTOKEN:-}" ]; then + echo "error: SYSTEM_ACCESSTOKEN environment variable must be set" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Check for overriden key code, otherwise use default (Microsoft Third-Party/OSS) +ESRP_KEYCODE="${ESRP_KEYCODE:-CP-231522}" + +# Create work dir and resolve its Windows path by cd-ing into it. +WORK_DIR="$(mktemp -d)" +WORK_DIR_WIN="$(cd "$WORK_DIR" && pwd -W | sed 's|/|\\|g')" + +echo "==> ESRP signing tool: $ESRP_TOOL" +echo "==> Working directory: $WORK_DIR" + +if [ ! -f "$ESRP_TOOL" ]; then + echo "error: ESRPClient.exe not found at $ESRP_TOOL" >&2 + exit 1 +fi + +# Convert an MSYS2 path to Windows format for ESRPClient.exe. +to_windows_path () { + # Prefer cygpath if available (full Git for Windows) + if command -v cygpath >/dev/null 2>&1; then + cygpath -w "$1" + return + fi + case "$1" in + /[a-zA-Z]/*) + # Drive path: /d/path -> D:\path + drive=$(echo "$1" | cut -c2 | tr 'a-z' 'A-Z') + rest=$(echo "$1" | cut -c3-) + echo "${drive}:${rest}" | sed 's|/|\\|g' + ;; + /*) + # Absolute path under MSYS2 root + root=$(cd / && pwd -W) + echo "${root}${1}" | sed 's|/|\\|g' + ;; + # Relative or already-Windows path: just flip slashes + *) + echo "$1" | sed 's|/|\\|g' + ;; + esac +} + +# Build the SignRequestFiles JSON array +echo "==> Preparing files for signing ($# file(s))..." +files_json="" +for file in "$@"; do + if [ ! -f "$file" ]; then + echo "error: file not found: $file" >&2 + exit 1 + fi + + abs_path="$(cd "$(dirname "$file")" && pwd)/$(basename "$file")" + win_path="$(to_windows_path "$abs_path")" + # Escape backslashes for JSON + win_path_escaped="${win_path//\\/\\\\}" + echo " - $win_path" + + if [ -n "$files_json" ]; then + files_json+="," + fi + files_json+=" + { + \"SourceLocation\": \"$win_path_escaped\", + \"DestinationLocation\": \"$win_path_escaped\" + }" +done + +# Generate the input JSON +input_json="$WORK_DIR/input.json" +output_json="$WORK_DIR/output.json" + +echo "==> Generating input JSON: $input_json" +cat > "$input_json" <<-EOF + { + "Version": "1.0.0", + "SignBatches": [ + { + "SourceLocationType": "UNC", + "DestinationLocationType": "UNC", + "SignRequestFiles": [$files_json + ], + "SigningInfo": { + "Operations": [ + { + "KeyCode": "$ESRP_KEYCODE", + "OperationCode": "SigntoolSign", + "ToolName": "sign", + "ToolVersion": "1.0", + "Parameters": { + "OpusName": "Microsoft", + "OpusInfo": "https://www.microsoft.com", + "FileDigest": "/fd SHA256", + "PageHash": "/NPH", + "TimeStamp": "/tr \"http://rfc3161.gtm.corp.microsoft.com/TSS/HttpTspServer\" /td sha256" + } + }, + { + "KeyCode": "$ESRP_KEYCODE", + "OperationCode": "SigntoolVerify", + "ToolName": "sign", + "ToolVersion": "1.0", + "Parameters": {} + } + ] + } + } + ] + } +EOF + +# Generate policy JSON +echo "==> Generating policy JSON..." +policy_json="$WORK_DIR/policy.json" +cat > "$policy_json" <<-EOF + { + "Version": "1.0.0", + "Intent": "ProductRelease", + "ContentType": "Binaries", + "ContentOrigin": "1stParty", + "ProductState": "Current", + "Audience": "ExternalBroad" + } +EOF + +# Use auth JSON from ESRP_AUTH +export ESRP_AUTH_CONFIG="$(to_windows_path "$ESRP_AUTH")" +export ESRP_POLICY_CONFIG="$WORK_DIR_WIN\\policy.json" + +# The ADO system access token is referenced in the auth JSON via the environment +# variable - export this so the ESRP client can pick it up when it runs. +export SYSTEM_ACCESSTOKEN + +# Print generated JSON files for debugging +echo "==> Auth JSON:" +cat "$ESRP_AUTH" +echo "" +echo "==> Policy JSON:" +cat "$policy_json" +echo "" +echo "==> Input JSON:" +cat "$input_json" +echo "" + +# Sign the files +esrp_tool_win="$(to_windows_path "$ESRP_TOOL")" +input_json_win="$WORK_DIR_WIN\\input.json" +output_json_win="$WORK_DIR_WIN\\output.json" + +echo "==> ESRP_AUTH_CONFIG=$ESRP_AUTH_CONFIG" +echo "==> ESRP_POLICY_CONFIG=$ESRP_POLICY_CONFIG" +echo "==> Running: $esrp_tool_win sign -i $input_json_win -o $output_json_win" +"$esrp_tool_win" sign \ + -i "$input_json_win" \ + -o "$output_json_win" + +echo "==> Signing complete." +echo "==> Output JSON:" +cat "$output_json" diff --git a/.azure-pipelines/esrp/windows/setup.yml b/.azure-pipelines/esrp/windows/setup.yml new file mode 100644 index 00000000000000..0653b868d0a728 --- /dev/null +++ b/.azure-pipelines/esrp/windows/setup.yml @@ -0,0 +1,69 @@ +parameters: + - name: serviceConnectionName + type: string + - name: esrpClientId + type: string + - name: keyVaultName + type: string + - name: signCertName + type: string + +steps: + - task: EsrpClientTool@4 + name: esrpinstall + displayName: 'Install ESRP client' + - task: AzureCLI@2 + displayName: 'Set up ESRP environment' + inputs: + azureSubscription: ${{ parameters.serviceConnectionName }} + addSpnToEnvironment: true + scriptType: ps + scriptLocation: inlineScript + inlineScript: | + # Resolve ESRP client tool path (passed via env to avoid PS subexpression issues) + $esrpTool = "$env:ESRPCLIENT_TOOLPATH\$env:ESRPCLIENT_TOOLNAME" + if (-not (Test-Path $esrpTool)) { Write-Error "ESRPClient.exe not found at $esrpTool"; exit 1 } + Write-Host "Found ESRP client: $esrpTool" + Write-Host "##vso[task.setvariable variable=ESRP_TOOL]$esrpTool" + + # Derive the service connection GUID from the ENDPOINT_URL_* env vars + # that the agent emits for the bound connection. Filter out the + # built-in SystemVssConnection which is always present. + $scId = (Get-ChildItem env:ENDPOINT_URL_*).Name ` + -replace '^ENDPOINT_URL_','' | + Where-Object { $_ -ne 'SYSTEMVSSCONNECTION' } + if (-not $scId) { Write-Error "Could not derive service connection GUID"; exit 1 } + Write-Host "Resolved service connection GUID: $scId" + + # servicePrincipalId and tenantId are provided by addSpnToEnvironment + $authJson = @{ + Version = "1.0.0" + AuthenticationType = "AAD_MSI_WIF" + EsrpClientId = "${{ parameters.esrpClientId }}" + ClientId = $env:servicePrincipalId + TenantId = $env:tenantId + AADAuthorityBaseUri = "https://login.microsoftonline.com/" + FederatedTokenData = @{ + JobId = "$(System.JobId)" + PlanId = "$(System.PlanId)" + ProjectId = "$(System.TeamProjectId)" + Hub = "$(System.HostType)" + Uri = "$(System.CollectionUri)" + ServiceConnectionId = $scId + SystemAccessToken = "SYSTEM_ACCESSTOKEN" + } + RequestSigningCert = @{ + GetCertFromKeyVault = $true + KeyVaultName = "${{ parameters.keyVaultName }}" + KeyVaultCertName = "${{ parameters.signCertName }}" + } + } | ConvertTo-Json -Depth 4 + + $authPath = "$(Agent.TempDirectory)\esrp-auth.json" + $authJson | Set-Content -Path $authPath -Encoding UTF8 + Write-Host "Generated ESRP auth JSON: $authPath" + Write-Host "##vso[task.setvariable variable=ESRP_AUTH]$authPath" + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + ESRPCLIENT_TOOLPATH: $(esrpinstall.esrpclient.toolpath) + ESRPCLIENT_TOOLNAME: $(esrpinstall.esrpclient.toolname) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index bd646d2bd40b01..aba4943438543f 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -10,6 +10,10 @@ resources: ref: refs/tags/release parameters: + - name: 'esrp' + type: boolean + default: false # TODO: change default to true after testing + displayName: 'Enable ESRP code signing' - name: 'github' type: boolean default: false # TODO: change default to true after testing @@ -72,8 +76,17 @@ parameters: deb_arch: arm64 variables: + - name: 'esrpAppConnectionName' + value: '1ESGitClient-ESRP-App' - name: 'githubConnectionName' value: 'GitHub-MicrosoftGit' + # ESRP signing variables set in the pipeline settings: + # - esrpEndpointUrl + # - esrpMI + # - esrpClientId + # - esrpTenantId + # - esrpKeyVaultName + # - esrpSignReqCertName extends: template: v1/1ES.Official.PipelineTemplate.yml@1ESPipelines @@ -138,6 +151,14 @@ extends: displayName: 'Add Git Bash to PATH' inputs: filename: ./.azure-pipelines/scripts/windows/setup-git-bash.cmd + # Setup ESRP code signing for Windows (sets ESRP_TOOL, ESRP_AUTH) + - ${{ if eq(parameters.esrp, true) }}: + - template: .azure-pipelines/esrp/windows/setup.yml@self + parameters: + serviceConnectionName: $(esrpAppConnectionName) + esrpClientId: $(esrpClientId) + keyVaultName: $(esrpKeyVaultName) + signCertName: $(esrpSignReqCertName) # TODO: add tasks to set up Git for Windows SDK # TODO: add tasks to build Git and installers - script: | @@ -148,6 +169,25 @@ extends: copy C:\Windows\System32\calc.exe $(Build.ArtifactStagingDirectory)\app\example2.exe copy C:\Windows\System32\calc.exe $(Build.ArtifactStagingDirectory)\app\example3.exe displayName: 'Dummy build' + # + # To sign Windows binaries with ESRP, call esrpsign.sh + # with the files to sign as arguments. Requires the + # following environment variables to be set: + # ESRP_TOOL - set by the setup template above + # ESRP_AUTH - set by the setup template above + # SYSTEM_ACCESSTOKEN - $(System.AccessToken) + # + - ${{ if eq(parameters.esrp, true) }}: + - bash: | + .azure-pipelines/esrp/windows/esrpsign.sh \ + "$BUILD_ARTIFACTSTAGINGDIRECTORY/app/example1.exe" \ + "$BUILD_ARTIFACTSTAGINGDIRECTORY/app/example2.exe" \ + "$BUILD_ARTIFACTSTAGINGDIRECTORY/app/example3.exe" + displayName: 'Example ESRP signing' + env: + ESRP_TOOL: $(ESRP_TOOL) + ESRP_AUTH: $(ESRP_AUTH) + SYSTEM_ACCESSTOKEN: $(System.AccessToken) # TODO: put final artifacts under $(Build.ArtifactStagingDirectory)/_final - script: | mkdir $(Build.ArtifactStagingDirectory)\_final @@ -182,6 +222,25 @@ extends: mkdir -p $(Build.ArtifactStagingDirectory)/app cp /bin/echo $(Build.ArtifactStagingDirectory)/app/example displayName: 'Dummy build' + - ${{ if eq(parameters.esrp, true) }}: + - template: .azure-pipelines/esrp/sign.yml@self + parameters: + displayName: 'Example sign binaries' + folderPath: '$(Build.ArtifactStagingDirectory)/app' + pattern: '**/*' + useArchive: true # Must be true when macOS signing + inlineOperation: | + [ + { + "KeyCode": "CP-401337-Apple", + "OperationCode": "MacAppDeveloperSign", + "ToolName": "sign", + "ToolVersion": "1.0", + "Parameters": { + "Hardening": "Enable" + } + } + ] # TODO: put final artifacts under $(Build.ArtifactStagingDirectory)/_final - script: | mkdir -p $(Build.ArtifactStagingDirectory)/_final @@ -229,6 +288,22 @@ extends: CTRL dpkg-deb --build $debroot $(Build.ArtifactStagingDirectory)/app/example_$(deb_arch).deb displayName: 'Dummy build' + - ${{ if eq(parameters.esrp, true) }}: + - template: .azure-pipelines/esrp/sign.yml@self + parameters: + displayName: 'Example sign Debian package' + folderPath: '$(Build.ArtifactStagingDirectory)/app' + pattern: '**/*.deb' + inlineOperation: | + [ + { + "KeyCode": "CP-453387-Pgp", + "OperationCode": "LinuxSign", + "ToolName": "sign", + "ToolVersion": "1.0", + "Parameters": {} + } + ] # TODO: put final artifacts under $(Build.ArtifactStagingDirectory)/_final - script: | mkdir -p $(Build.ArtifactStagingDirectory)/_final From b1ea20f4c298cb086bd834445d60c3cc7442b597 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 30 Apr 2026 12:32:00 +0100 Subject: [PATCH 3/5] azure-pipelines: install .NET SDK on Linux for ESRP signing The Linux hosted agents do not have .NET pre-installed, but the EsrpCodeSigning ADO task requires it. Add a UseDotNet@2 step to install the .NET 8 SDK before invoking the ESRP signing template in Linux build jobs. Signed-off-by: Matthew John Cheetham --- .azure-pipelines/release.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index aba4943438543f..33be3dbb9776aa 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -289,6 +289,13 @@ extends: dpkg-deb --build $debroot $(Build.ArtifactStagingDirectory)/app/example_$(deb_arch).deb displayName: 'Dummy build' - ${{ if eq(parameters.esrp, true) }}: + # ESRP ADO tasks require .NET, so we install it here since the + # Linux images do not have it by default. + - task: UseDotNet@2 + displayName: 'Install .NET for ESRP' + inputs: + packageType: sdk + version: '8.x' - template: .azure-pipelines/esrp/sign.yml@self parameters: displayName: 'Example sign Debian package' From 9749c3979e968950671f277f3b4a5d1efeea67ca Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 30 Apr 2026 12:33:24 +0100 Subject: [PATCH 4/5] azure-pipelines: install Azure CLI on Windows arm64 agents The arm64 Windows hosted agents do not have Azure CLI pre-installed, which is required by the AzureCLI@2 task used in the ESRP setup step. Install the x64 MSI (which runs under x86-64 emulation on arm64 Windows) and prepend it to the PATH. This step only runs on arm64 jobs via a poolArch condition. This is a workaround until a bug preventing us from baking the Azure CLI into the hosted pool image is fixed, at which point this step can be removed. Signed-off-by: Matthew John Cheetham --- .azure-pipelines/release.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 33be3dbb9776aa..fee881fd96d93c 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -151,6 +151,19 @@ extends: displayName: 'Add Git Bash to PATH' inputs: filename: ./.azure-pipelines/scripts/windows/setup-git-bash.cmd + # Install Azure CLI on arm64 (not pre-installed on these agents) + - ${{ if eq(dim.poolArch, 'arm64') }}: + - powershell: | + $ProgressPreference = 'SilentlyContinue' + $msi = "$env:TEMP\AzureCLI.msi" + Write-Host "Downloading Azure CLI (x64)..." + Invoke-WebRequest -Uri https://aka.ms/installazurecliwindows -OutFile $msi + Write-Host "Installing Azure CLI..." + Start-Process msiexec.exe -ArgumentList "/i", $msi, "/quiet", "/norestart" -Wait + $azPath = "C:\Program Files (x86)\Microsoft SDKs\Azure\CLI2\wbin" + Write-Host "##vso[task.prependpath]$azPath" + Write-Host "Azure CLI installed." + displayName: 'Install Azure CLI (arm64)' # Setup ESRP code signing for Windows (sets ESRP_TOOL, ESRP_AUTH) - ${{ if eq(parameters.esrp, true) }}: - template: .azure-pipelines/esrp/windows/setup.yml@self From 885549d523a91bb7bc0697a4234d5f604cc75d3d Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 30 Apr 2026 12:43:47 +0100 Subject: [PATCH 5/5] TO-DROP: use static version v9.99.99.vfs.0.0 for testing Signed-off-by: Matthew John Cheetham --- .azure-pipelines/release.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index fee881fd96d93c..f0e79b31926149 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -115,8 +115,19 @@ extends: displayName: 'Resolve version and tag information' name: info inputs: - targetType: filePath - filePath: .azure-pipelines/scripts/resolve-version.sh + targetType: inline + script: | + set -euo pipefail + tag_name="v9.99.99.vfs.0.0" + tag_sha="$(git rev-parse HEAD)" + git_version="9.99.99.vfs.0.0" + echo "Git version: $git_version" + echo "Tag name: $tag_name" + echo "Tag SHA: $tag_sha" + echo "##vso[task.setvariable variable=git_version;isOutput=true;isReadOnly=true]$git_version" + echo "##vso[task.setvariable variable=tag_name;isOutput=true;isReadOnly=true]$tag_name" + echo "##vso[task.setvariable variable=tag_sha;isOutput=true;isReadOnly=true]$tag_sha" + echo "##vso[build.updatebuildnumber]${tag_name} ($(Build.BuildNumber))" - stage: build displayName: 'Build'