From 27c1938a528d1d097d426389d482d8a2c980b77b Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 30 Apr 2026 12:16:59 +0100 Subject: [PATCH 01/23] 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 | 222 +++++++++++++----- .azure-pipelines/scripts/resolve-version.sh | 48 ++++ .../scripts/windows/setup-git-bash.cmd | 13 + 3 files changed, 228 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..e8cc617fb887d7 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,24 @@ 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' + # macOS-latest is an Intel x86_64 Mac Pro, which can't host the + # arm64 Homebrew the universal-binary build needs. We need to + # explictly target the newer Apple Silicon machines that can run + # both x86_64 and arm64 builds. + image: macOS-15-arm64 + os: macos - name: linux_matrix type: object @@ -48,6 +60,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 +69,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 +99,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 +123,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 +137,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 +202,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 +221,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 +333,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 478f1904aeb1b2581ce088e446ee208bb82e227a Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 30 Apr 2026 12:27:46 +0100 Subject: [PATCH 02/23] 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. The Linux hosted agents do not ship with .NET, but the EsrpCodeSigning task requires it. Install the .NET 8 SDK via a UseDotNet@2 step before invoking the ESRP signing template in each Linux build job, so that ESRP signing on Linux works out of the box without further per-platform plumbing. 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 | 82 +++++++++ 4 files changed, 455 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 e8cc617fb887d7..471d8ea4af0a00 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 @@ -76,8 +80,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 @@ -142,6 +155,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: | @@ -152,6 +173,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 @@ -186,6 +226,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 @@ -233,6 +292,29 @@ extends: CTRL 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' + 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 ca8b3e1ff732b510d64a0d912373fe34b592fdfc Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 30 Apr 2026 15:37:56 +0200 Subject: [PATCH 03/23] azure-pipelines: install build dependencies for Linux build Port the build-dependency setup from the GitHub workflow's create-linux-unsigned-artifacts job (.github/workflows/build-git-installers.yml). The package list matches one for one: build-essential for the C toolchain, tcl tk for git-gui, gettext for i18n, asciidoc and xmlto for documentation, libcurl4-gnutls-dev / libpcre2-dev / zlib1g-dev / libexpat-dev for the Git build flags this pipeline will use, and curl / ca-certificates for any in-job downloads. The GitHub workflow runs all of this inside an ubuntu:20.04 / ubuntu:22.04 container, both to pin the resulting .deb's glibc ABI floor and to give apt-get a root-owned filesystem. The 1ES pool images we run on (GitClientPME-1ESHostedPool-{intel,arm64}-pc) silently ignore a job-level `container:` directive, so the build executes on the bare Ubuntu host VM as the unprivileged agent user. Run apt-get via `sudo`, and to at least pin the .deb's glibc floor to something an audit can read back, log the running Ubuntu version, kernel, and effective UID at the start of the job. Two pieces of the workflow's setup are intentionally left out: The DEBIAN_FRONTEND=noninteractive / TZ=Etc/UTC env vars exist only to keep `tzdata` from prompting interactively when it gets pulled in inside the container (see 842cfa4aaaae0 (fixup! release: build unsigned Ubuntu .deb package, 2025-02-13)); the bare 1ES image already has tzdata configured and they would have no effect. The Node.js workaround exists to satisfy GitHub Actions' Node-based shim, which Azure Pipelines does not need. Re-introducing a real container later (whether via 1ES's container option, a custom container job, or a build inside docker invoked from a step) is a separate question. Assisted-by: Claude Opus 4.7 Signed-off-by: Johannes Schindelin --- .azure-pipelines/release.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 471d8ea4af0a00..a02587118399be 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -275,7 +275,26 @@ extends: artifactName: '${{ dim.id }}' steps: - checkout: self - # TODO: add tasks to set up build environment + - task: Bash@3 + displayName: 'Log build environment' + inputs: + targetType: inline + script: | + lsb_release -a || true + uname -a + id + - task: Bash@3 + displayName: 'Install build dependencies' + inputs: + targetType: inline + script: | + set -euo pipefail + sudo apt-get update -q + sudo apt-get install -y -q --no-install-recommends \ + build-essential \ + tcl tk gettext asciidoc xmlto \ + libcurl4-gnutls-dev libpcre2-dev zlib1g-dev libexpat-dev \ + curl ca-certificates # TODO: add tasks to build Git and installers - script: | echo $(cc_arch) From 318ea0417231bd0853ac86b7280bdb15a1b198bc Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 30 Apr 2026 16:28:32 +0200 Subject: [PATCH 04/23] azure-pipelines: build microsoft-git Debian package for Linux Replace the Linux dummy build with the real package build, ported from the create-linux-unsigned-artifacts job in .github/workflows/build-git-installers.yml. The pipeline already wires $(git_version) from the prereqs stage, so the version no longer comes from a tag_version job output. $(deb_arch) is taken straight from the matrix entry, dropping the GitHub workflow's runtime dpkg-architecture round-trip; the matrix already names amd64 and arm64 explicitly. Parallelism switches from the workflow's hard-coded -j5 (a runner-specific holdover) to -j$(nproc), which is the common default and adapts to whatever the 1ES pool gives us. The shell prologue switches from `set -ex` to `set -euo pipefail` so an unbound variable or a failed step in a pipeline aborts the job rather than silently producing a broken .deb. The make recipe and DEBIAN/control body match the workflow byte for byte: same DESTDIR layout, same Make flags (USE_LIBPCRE, USE_CURL_FOR_IMAP_SEND, NO_OPENSSL, NO_CROSS_DIRECTORY_HARDLINKS, ASCIIDOC8, ASCIIDOC_NO_ROFF, ASCIIDOC='TZ=UTC asciidoc'), same prefix and gitexecdir/libexecdir/htmldir, same install targets, and the same Depends list and Description text. The only intentional content change is the package version and architecture fields, which now come from $(git_version) and $(deb_arch). The sed 's/-rc/.rc/g' substitution carried over because Git's GIT-VERSION-GEN expects rc tags spelled with a dot. Output goes under $(Build.ArtifactStagingDirectory)/app/, where the existing ESRP signing template will pick it up via its '**/*.deb' pattern. The collect step below still uses the dummy 'cp -R app/* _final/' shape; tightening that is the next commit. Assisted-by: Claude Opus 4.7 Signed-off-by: Johannes Schindelin --- .azure-pipelines/release.yml | 62 ++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index a02587118399be..48ad8876601bb3 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -295,22 +295,52 @@ extends: tcl tk gettext asciidoc xmlto \ libcurl4-gnutls-dev libpcre2-dev zlib1g-dev libexpat-dev \ curl ca-certificates - # TODO: add tasks to build Git and installers - - script: | - echo $(cc_arch) - echo $(deb_arch) - mkdir -p $(Build.ArtifactStagingDirectory)/app - debroot=$(Build.ArtifactStagingDirectory)/pkgroot - mkdir -p $debroot/DEBIAN - cat > $debroot/DEBIAN/control <version + make GIT-VERSION-FILE + + PKGNAME="microsoft-git_${VERSION}_$(deb_arch)" + PKGDIR="$(Build.ArtifactStagingDirectory)/pkgroot" + rm -rf "$PKGDIR" + mkdir -p "$PKGDIR/DEBIAN" + + DESTDIR="$PKGDIR" make -j"$(nproc)" V=1 DEVELOPER=1 \ + USE_LIBPCRE=1 \ + USE_CURL_FOR_IMAP_SEND=1 NO_OPENSSL=1 \ + NO_CROSS_DIRECTORY_HARDLINKS=1 \ + ASCIIDOC8=1 ASCIIDOC_NO_ROFF=1 \ + ASCIIDOC='TZ=UTC asciidoc' \ + prefix=/usr/local \ + gitexecdir=/usr/local/lib/git-core \ + libexecdir=/usr/local/lib/git-core \ + htmldir=/usr/local/share/doc/git/html \ + install install-doc install-html + + # Based on https://packages.ubuntu.com/xenial/vcs/git + cat >"$PKGDIR/DEBIAN/control" < + Description: Git client built from the https://github.com/microsoft/git repository, + specialized in supporting monorepo scenarios. Includes the Scalar CLI. + CTRL + + mkdir -p "$(Build.ArtifactStagingDirectory)/app" + dpkg-deb -Zxz --build "$PKGDIR" \ + "$(Build.ArtifactStagingDirectory)/app/$PKGNAME.deb" - ${{ 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. From c0a9265cbfc2b54db1130615279756f3072c82ac Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 30 Apr 2026 16:31:50 +0200 Subject: [PATCH 05/23] azure-pipelines: stage signed Debian package for upload Replace the Linux dummy collect step with a focused move of just the signed microsoft-git__.deb into $(Build.ArtifactStagingDirectory)/_final/, which the existing templateContext.outputs.pipelineArtifact already publishes as the linux_x64 / linux_arm64 artifact. The dummy version copied everything under app/* into _final/, which worked in isolation but would have started silently uploading any intermediate files (including the pkgroot/ staging tree the build step now writes alongside the .deb). Naming the file precisely also turns "ESRP signed something else" into a missing-file error rather than a silent wrong-artifact upload. Assisted-by: Claude Opus 4.7 Signed-off-by: Johannes Schindelin --- .azure-pipelines/release.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 48ad8876601bb3..5e61845e0f4a08 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -364,11 +364,15 @@ extends: "Parameters": {} } ] - # 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' + - task: Bash@3 + displayName: 'Stage Debian package for upload' + inputs: + targetType: inline + script: | + set -euo pipefail + mkdir -p "$(Build.ArtifactStagingDirectory)/_final" + mv "$(Build.ArtifactStagingDirectory)/app/microsoft-git_$(git_version)_$(deb_arch).deb" \ + "$(Build.ArtifactStagingDirectory)/_final/" - stage: release displayName: 'Release' From 6296dbbe5f7ad0a308d048b6b1179a40e4154617 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 30 Apr 2026 17:09:27 +0200 Subject: [PATCH 06/23] azure-pipelines: install macOS build dependencies Port the dual-architecture Homebrew install from the GitHub workflow's create-macos-artifacts job (.github/workflows/build-git-installers.yml). The native Homebrew on the macOS-15-arm64 pool image is arm64 and lives at /opt/homebrew. To produce a universal binary we additionally need the x86_64 build of gettext/libintl, so install a separate x86_64 Homebrew under /usr/local via the upstream installer running under Rosetta and pull gettext from there as well. The native arm64 brew installs the rest of the build chain: automake, asciidoc, xmlto, and docbook (the same set the workflow installs). The two arch-specific libintl.a copies are then combined with lipo into a universal archive at the workspace root, where the upcoming config.mak's LDFLAGS = -L"$(pwd)" will find it. libintl depends on iconv, but the system /usr/lib/libiconv.dylib is already universal and exports the _iconv* symbols Homebrew's gettext was built against; Homebrew's own libiconv exports _libiconv* and would not link, which is why the comment from the workflow is preserved here. Spotlight indexing on the boot volume is disabled at the start of the job (`mdutil -i off /`); leaving it on caused intermittent file-locking failures in subsequent build steps. Assisted-by: Claude Opus 4.7 Signed-off-by: Johannes Schindelin --- .azure-pipelines/release.yml | 41 +++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 5e61845e0f4a08..139598f7090b5e 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -219,7 +219,46 @@ extends: artifactName: '${{ dim.id }}' steps: - checkout: self - # TODO: add tasks to set up build environment + - task: Bash@3 + displayName: 'Disable Spotlight indexing' + inputs: + targetType: inline + script: | + # Disable Spotlight indexing to prevent file + # locking issues. + set -euo pipefail + sudo mdutil -i off / || true + - task: Bash@3 + displayName: 'Install build dependencies' + inputs: + targetType: inline + script: | + set -euo pipefail + + # The agent's native arm64 Homebrew lives at + # /opt/homebrew. Install a separate x86_64 Homebrew + # under /usr/local so we can fetch the x86_64 build + # of gettext/libintl alongside the arm64 one. + arch -x86_64 /bin/bash -c \ + "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + arch -x86_64 /usr/local/bin/brew install gettext + + # Native (arm64) build dependencies. + brew install automake asciidoc xmlto docbook + brew link --force gettext + + # Make a universal libintl.a out of the two + # arch-specific copies, dropped at the workspace + # root so the build's LDFLAGS = -L"$(pwd)" can find + # it. libintl depends on iconv, but we deliberately + # use the system's universal /usr/lib/libiconv.dylib + # rather than Homebrew's libiconv (which exports + # _libiconv* symbols, while Homebrew's gettext was + # built against system iconv with _iconv* symbols). + lipo -create \ + -output libintl.a \ + /usr/local/opt/gettext/lib/libintl.a \ + /opt/homebrew/opt/gettext/lib/libintl.a # TODO: add tasks to build Git and installers - script: | echo "Hello, Mac!" From 02997b8e9f66225cd1e657b0ef0a9e56bfddf542 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 30 Apr 2026 17:12:42 +0200 Subject: [PATCH 07/23] azure-pipelines: configure universal macOS build Port the config.mak generation from the GitHub workflow's create-macos-artifacts job. With the universal libintl.a from the previous step in place, the build needs the Make flags that turn on the dual-arch compile and that route around several macOS quirks: HOST_CPU=universal, dual -arch CFLAGS (the actual universal-binary driver), -DNO_OPENSSL for contrib Makefiles that do not see the main Makefile's NO_OPENSSL handling, and explicit USE_HOMEBREW_LIBICONV/ICONVDIR overrides so we link against the universal /usr/lib/libiconv.dylib (whose unprefixed _iconv* symbols are what Homebrew's gettext was built against, unlike Homebrew's own libiconv with its prefixed _libiconv* symbols). CFLAGS adds the gettext include dirs from both Homebrew prefixes; LDFLAGS = -L"$(pwd)" so the linker finds the universal libintl.a in the workspace root. CURL_LDFLAGS / CURL_CONFIG pin against the OS-supplied libcurl rather than a Homebrew copy. Finally, SKIP_DASHED_BUILT_INS disables the dashed built-ins; on macOS the hard-link optimisation does not kick in for the staging tree and the resulting full copies would bloat the eventual .dmg. Compared with the GitHub workflow, the differences are mechanical: the source tree is at $(Build.SourcesDirectory) (no actions/checkout "path: git" subdir), so config.mak and the version file land at the worktree root; $(git_version) replaces tag_version; and the script prologue is set -euo pipefail rather than set -ex. Assisted-by: Claude Opus 4.7 Signed-off-by: Johannes Schindelin --- .azure-pipelines/release.yml | 58 ++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 139598f7090b5e..1827ff4a42f2b0 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -259,6 +259,64 @@ extends: -output libintl.a \ /usr/local/opt/gettext/lib/libintl.a \ /opt/homebrew/opt/gettext/lib/libintl.a + - task: Bash@3 + displayName: 'Configure universal build' + inputs: + targetType: inline + script: | + set -euo pipefail + + VERSION="$(git_version)" + # Git's GIT-VERSION-GEN expects .rc rather than -rc + BUILD_VERSION="$(echo "$VERSION" | sed 's/-rc/.rc/g')" + echo "$BUILD_VERSION" >version + + # HOST_CPU is a bit of a lie and is only used in + # 'git version --build-options'; we'll fix that in + # code. The two -arch flags are what actually drive + # the universal build. + cat >config.mak <>config.mak <>config.mak <>config.mak # TODO: add tasks to build Git and installers - script: | echo "Hello, Mac!" From 39138ddf9a0a4ef4451d8da361bca6dee991d068 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 30 Apr 2026 17:16:43 +0200 Subject: [PATCH 08/23] azure-pipelines: build payload via macos-installer Makefile Port the build sequence from the GitHub workflow's create-macos-artifacts step. Runs `make GIT-VERSION-FILE dist dist-doc` in the source tree, recovers the original commit OID from the resulting source tarball with `git get-tar-commit-id` (this becomes GIT_BUILT_FROM_COMMIT, which the macos-installer Makefile bakes into `git version --build-options`), then extracts the source and manpage tarballs into payload/ and manpages/, drops a copy of the worktree config.mak inside the extracted source so the universal-build flags apply during the real compile, and finally runs `make -C .github/macos-installer payload` to produce the universal binary tree. `git get-tar-commit-id` reads only the leading pax header and then closes its stdin, which makes `gunzip -c` exit 141 (SIGPIPE) under the outer `set -o pipefail`. The pipeline is wrapped in a `set +o pipefail` subshell so the SIGPIPE does not abort the build. The macos-installer Makefile produces the install tree at stage/git-universal-/ but its `pkg` target packages from build-artifacts/, so we mirror the GitHub workflow by copying the tree across after `make payload` completes. (FUTURE: the duplication exists only because .github/macos-installer/Makefile hardcodes both DESTDIR=stage/... and ARTIFACTDIR=build-artifacts; overriding ARTIFACTDIR on the `make pkg` line below to point at stage/ would let us drop the cp entirely. Worth cleaning up alongside moving the macOS installer Makefiles out of .github/, which are build infrastructure rather than GitHub-specific.) The macos-installer Makefile derives BUILD_DIR from $(GITHUB_WORKSPACE), which is unset under ADO. Setting GITHUB_WORKSPACE=$(Build.SourcesDirectory) in the task env block gives the Makefile the same anchor it had under GitHub Actions, so payload/git-$VERSION/ ends up where the targets expect it. XML_CATALOG_FILES is exported to the catalogs from the Homebrew docbook installed in the dependencies step, so asciidoc/xmlto can resolve their DTDs during dist-doc. VERSION is exported because the macos-installer Makefile reads it directly. The .rc/-rc rewrite stays consistent with the config-generation step: the on-disk version file has the .rc form (Git's GIT-VERSION-GEN convention), while the package and artifact filenames keep the original tag spelling (which is what the Makefile's ORIGINAL_VERSION tracks). Assisted-by: Claude Opus 4.7 Signed-off-by: Johannes Schindelin --- .azure-pipelines/release.yml | 77 ++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 1827ff4a42f2b0..e8a1d579c762ba 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -317,6 +317,83 @@ extends: # they end up as full copies instead and bloat the # .dmg indecently. Skip them entirely. echo 'SKIP_DASHED_BUILT_INS = YabbaDabbaDoo' >>config.mak + - task: Bash@3 + displayName: 'Build payload via macos-installer' + env: + # The macos-installer Makefile derives BUILD_DIR from + # $(GITHUB_WORKSPACE), which is unset in ADO. Point it + # at the worktree root. + GITHUB_WORKSPACE: $(Build.SourcesDirectory) + inputs: + targetType: inline + script: | + set -euo pipefail + + VERSION="$(git_version)" + BUILD_VERSION="$(echo "$VERSION" | sed 's/-rc/.rc/g')" + + # The asciidoc/xmlto build steps need the catalogs + # from Homebrew docbook. + export XML_CATALOG_FILES="$(brew --prefix)/etc/xml/catalog" + + # `git commit` (in dist-doc) forks a detached + # `git maintenance run --auto` that keeps writing + # into .git/ after the commit returns, which then + # races with dist-doc's `rm -fr .doc-tmp-dir` and + # produces "Directory not empty". Disable + # auto-maintenance for every git invocation in + # this build. + export GIT_CONFIG_PARAMETERS="'maintenance.auto=false'" + + make -j"$(sysctl -n hw.physicalcpu)" GIT-VERSION-FILE dist dist-doc + + # Recover the source-tree commit OID from the dist + # tarball; the macos-installer Makefile bakes it + # into 'git version --build-options'. + # `git get-tar-commit-id` reads only the leading + # pax header and then closes its stdin, which + # makes `gunzip -c` exit 141 (SIGPIPE) under the + # outer `set -o pipefail`. Disable pipefail for + # the duration of this one pipeline. + GIT_BUILT_FROM_COMMIT="$( + set +o pipefail + gunzip -c "git-$BUILD_VERSION.tar.gz" | + git get-tar-commit-id + )" + export GIT_BUILT_FROM_COMMIT + export VERSION + + mkdir payload manpages + tar -xf "git-$BUILD_VERSION.tar.gz" -C payload + tar -xf "git-manpages-$BUILD_VERSION.tar.gz" -C manpages + + # The actual compile happens inside the extracted + # tree, against a copy of the config.mak we wrote + # at the worktree root in the previous step. + cp config.mak "payload/git-$BUILD_VERSION/config.mak" + + make -C .github/macos-installer V=1 payload + + # NOTE: the macos-installer Makefile produces + # the install tree at stage/git-universal-/ + # but its `pkg` target packages from + # build-artifacts/. Mirror the GitHub workflow + # by copying the tree across so the rest of the + # pipeline (signing, pkg) finds it where the + # Makefile expects. + # + # FUTURE: this duplication exists only because + # .github/macos-installer/Makefile hardcodes + # both DESTDIR (=stage/...) and ARTIFACTDIR + # (=build-artifacts). Overriding ARTIFACTDIR on + # the `make pkg` line below to point at stage/ + # would let us drop the cp entirely. Worth + # cleaning up alongside moving the macOS + # installer Makefiles out of .github/ (they are + # build infrastructure, not GitHub-specific). + mkdir -p .github/macos-installer/build-artifacts + cp -R "stage/git-universal-$BUILD_VERSION/." \ + .github/macos-installer/build-artifacts/ # TODO: add tasks to build Git and installers - script: | echo "Hello, Mac!" From 06a2023d33285c5ccc6ff9468dd7ab3416dc5853 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 30 Apr 2026 17:22:27 +0200 Subject: [PATCH 09/23] azure-pipelines: ESRP-sign macOS payload binaries Replace the dummy "Hello, Mac!" build placeholder and the dummy ESRP signing block with a real signing flow against the universal install tree built in the previous step. We sign the install tree at .github/macos-installer/build-artifacts/usr/local/git/, not the source tree under payload/git-/, because the macos-installer Makefile's `pkg` target packages from build-artifacts/ - signing the source tree would have no effect on the resulting .pkg. The pattern follows git-credential-manager/.azure-pipelines/release.yml: pre-filter the install tree to just the Mach-O files (using `file --mime` matching `mach`, the same heuristic the existing .github/scripts/codesign.sh uses), copy that subset into a staging directory under $(Build.ArtifactStagingDirectory)/macos-tosign/ preserving relative paths, hand the staging dir off to the existing .azure-pipelines/esrp/sign.yml template (which zips, signs via EsrpCodeSigning@6 with KeyCode CP-401337-Apple + OperationCode MacAppDeveloperSign + Hardening enabled, and extracts back into the staging dir), then copy the signed binaries back into the install tree. The pre-filter is necessary because the existing template's CopyFiles@2 step uses minimatch globs and the only reliable way to pick out Mach-O files is by file content. Signing the entire install tree would either fail on non-binary files or sign things that should not be signed (shell scripts, perl, manpages, templates, the uninstall.sh). UseDotNet@2 (8.x) installs the .NET SDK that EsrpCodeSigning@6 depends on; the macOS-15-arm64 pool image does not provide it. The whole block is gated on the `esrp` pipeline parameter, matching the existing convention in the file. This commit replaces both the dummy build (which produced $(Build.ArtifactStagingDirectory)/app/example) and the dummy ESRP call (which signed that fake artifact) with a real signing flow against the install tree we now build for real. The dummy collect step remains for now and is replaced when the .pkg/.dmg flow lands. Assisted-by: Claude Opus 4.7 Signed-off-by: Johannes Schindelin --- .azure-pipelines/release.yml | 62 ++++++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index e8a1d579c762ba..94f85e5a17e334 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -394,19 +394,54 @@ extends: mkdir -p .github/macos-installer/build-artifacts cp -R "stage/git-universal-$BUILD_VERSION/." \ .github/macos-installer/build-artifacts/ - # 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' + # ESRP-sign the universal Mach-O binaries inside the + # payload tree. The existing esrp/sign.yml template's + # CopyFiles@2 step uses minimatch globs and the only + # reliable way to detect Mach-O is by file content + # (file --mime), so we pre-filter into a staging dir, + # let the template zip/sign/extract that staging dir, + # then copy the signed binaries back over the payload. - ${{ if eq(parameters.esrp, true) }}: + # ESRP ADO tasks require .NET, which the macOS pool + # image does not provide by default. + - task: UseDotNet@2 + displayName: 'Install .NET for ESRP' + inputs: + packageType: sdk + version: '8.x' + - task: Bash@3 + displayName: 'Stage Mach-O binaries for signing' + inputs: + targetType: inline + script: | + set -euo pipefail + + # Sign the install tree (build-artifacts/) - + # this is what `make pkg` packages. Signing + # the source tree under payload/ would have + # no effect on the resulting .pkg. + install_tree=".github/macos-installer/build-artifacts/usr/local/git" + stage_dir="$(Build.ArtifactStagingDirectory)/macos-tosign/binaries" + + rm -rf "$stage_dir" + mkdir -p "$stage_dir" + + pushd "$install_tree" + find . -type f -exec file --mime {} + \ + | sed -n '/mach/s/: .*//p' \ + | while IFS= read -r f; do + rel="${f#./}" + tgt="$stage_dir/$rel" + mkdir -p "$(dirname "$tgt")" + cp -- "$f" "$tgt" + done + popd - template: .azure-pipelines/esrp/sign.yml@self parameters: - displayName: 'Example sign binaries' - folderPath: '$(Build.ArtifactStagingDirectory)/app' + displayName: 'ESRP-sign Mach-O binaries' + folderPath: '$(Build.ArtifactStagingDirectory)/macos-tosign/binaries' pattern: '**/*' - useArchive: true # Must be true when macOS signing + useArchive: true # Required for macOS signing inlineOperation: | [ { @@ -419,6 +454,15 @@ extends: } } ] + - task: Bash@3 + displayName: 'Copy signed binaries back to install tree' + inputs: + targetType: inline + script: | + set -euo pipefail + + cp -R "$(Build.ArtifactStagingDirectory)/macos-tosign/binaries"/* \ + .github/macos-installer/build-artifacts/usr/local/git/ # TODO: put final artifacts under $(Build.ArtifactStagingDirectory)/_final - script: | mkdir -p $(Build.ArtifactStagingDirectory)/_final From 248894873783c8e36711d064411d174acbdff61c Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 30 Apr 2026 17:35:54 +0200 Subject: [PATCH 10/23] azure-pipelines: build unsigned macOS installer pkg Run the macos-installer Makefile's `pkg` target to produce .github/macos-installer/disk-image/git--universal.pkg from the signed payload tree built and ESRP-signed in the preceding steps. Crucially we leave APPLE_INSTALLER_IDENTITY undefined. The Makefile's pkg_cmd has an `ifdef APPLE_INSTALLER_IDENTITY` branch that adds `--sign ""` to the pkgbuild invocation; with the variable unset, pkgbuild produces an unsigned .pkg, which ESRP then signs in the next commit. This is the whole point of the migration: replace the `productsign`-via-pkgbuild path with ESRP signing of the resulting .pkg. GITHUB_WORKSPACE and VERSION are exported for the same reason as in the payload step: the Makefile reads them directly and there is no GitHub Actions runtime under ADO to set them automatically. Assisted-by: Claude Opus 4.7 Signed-off-by: Johannes Schindelin --- .azure-pipelines/release.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 94f85e5a17e334..d538e7113c6154 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -463,6 +463,24 @@ extends: cp -R "$(Build.ArtifactStagingDirectory)/macos-tosign/binaries"/* \ .github/macos-installer/build-artifacts/usr/local/git/ + - task: Bash@3 + displayName: 'Build unsigned installer pkg' + env: + GITHUB_WORKSPACE: $(Build.SourcesDirectory) + inputs: + targetType: inline + script: | + set -euo pipefail + + VERSION="$(git_version)" + export VERSION + + # Leave APPLE_INSTALLER_IDENTITY undefined so the + # Makefile's `pkg` target produces an unsigned .pkg + # (the `ifdef APPLE_INSTALLER_IDENTITY` branch in + # pkg_cmd is skipped). ESRP signs it in the next + # step. + make -C .github/macos-installer V=1 pkg # TODO: put final artifacts under $(Build.ArtifactStagingDirectory)/_final - script: | mkdir -p $(Build.ArtifactStagingDirectory)/_final From 648d0e3306f7c86aa8f029fa1b6c7ff8cb79d035 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 30 Apr 2026 17:39:37 +0200 Subject: [PATCH 11/23] azure-pipelines: ESRP-sign macOS installer pkg Sign the unsigned .pkg produced in the previous step. The same ESRP path used for the Mach-O binaries works here too: KeyCode CP-401337-Apple covers both Developer ID Application and Developer ID Installer certs in this account, so MacAppDeveloperSign on a .pkg is the productsign equivalent. git-credential-manager/.azure-pipelines/release.yml uses exactly this pattern (same KeyCode and OperationCode, just pointed at the .pkg instead of the binary tree), so the sign-then-extract cycle through the existing esrp/sign.yml template applies unchanged. folderPath is the macos-installer's disk-image/ directory, where the previous step deposited the unsigned .pkg; the template's ExtractFiles@1 writes the signed .pkg back over it. This replaces the productsign --sign branch in the macos-installer Makefile's pkg_cmd, which we deliberately did not exercise (we left APPLE_INSTALLER_IDENTITY undefined in the previous commit so pkgbuild produced an unsigned .pkg). Assisted-by: Claude Opus 4.7 Signed-off-by: Johannes Schindelin --- .azure-pipelines/release.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index d538e7113c6154..85971c075b91db 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -481,6 +481,25 @@ extends: # pkg_cmd is skipped). ESRP signs it in the next # step. make -C .github/macos-installer V=1 pkg + - ${{ if eq(parameters.esrp, true) }}: + - template: .azure-pipelines/esrp/sign.yml@self + parameters: + displayName: 'ESRP-sign installer pkg' + folderPath: '.github/macos-installer/disk-image' + pattern: '*.pkg' + useArchive: true # Required for 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 From 54e9eab224b31521ee30bcbd0c8d3f042b652fd1 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 30 Apr 2026 19:08:34 +0200 Subject: [PATCH 12/23] azure-pipelines: ESRP-notarize macOS installer pkg Submit the signed .pkg through ESRP for Apple notarization. Same KeyCode (CP-401337-Apple) and same template as the previous two ESRP calls, but a different OperationCode (MacAppNotarize) and a required BundleId parameter. The bundle identifier comes straight from the macos-installer Makefile's pkg_cmd, which invokes `pkgbuild --identifier com.git.pkg`. Reusing it here keeps the notarization request consistent with the actual identifier baked into the package. This replaces the `xcrun notarytool submit ... --wait` plus `xcrun stapler staple` flow that .github/scripts/notarize.sh performs in the GitHub workflow's `make notarize` target. The ESRP MacAppNotarize operation handles both the submission and the ticket stapling, returning the notarized .pkg back into disk-image/ via the same zip-extract template path the previous sign step used. git-credential-manager/.azure-pipelines/release.yml uses the same operation with its own BundleId; that pipeline is the working reference for this combination of KeyCode, OperationCode, and useArchive: true. Assisted-by: Claude Opus 4.7 Signed-off-by: Johannes Schindelin --- .azure-pipelines/release.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 85971c075b91db..742a8d51e303ad 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -500,6 +500,24 @@ extends: } } ] + - template: .azure-pipelines/esrp/sign.yml@self + parameters: + displayName: 'ESRP-notarize installer pkg' + folderPath: '.github/macos-installer/disk-image' + pattern: '*.pkg' + useArchive: true # Required for macOS notarization + inlineOperation: | + [ + { + "KeyCode": "CP-401337-Apple", + "OperationCode": "MacAppNotarize", + "ToolName": "sign", + "ToolVersion": "1.0", + "Parameters": { + "BundleId": "com.git.pkg" + } + } + ] # TODO: put final artifacts under $(Build.ArtifactStagingDirectory)/_final - script: | mkdir -p $(Build.ArtifactStagingDirectory)/_final From 7664c8397f846082800abda1f0d5d04f1f25be09 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 30 Apr 2026 19:17:43 +0200 Subject: [PATCH 13/23] azure-pipelines: build macOS DMG and stage artifacts for upload Run the macos-installer Makefile's `image` target to build .github/macos-installer/git--universal.dmg from the contents of disk-image/, which by this point contains the signed and notarized .pkg from the previous ESRP step. hdiutil's behaviour is unchanged from the GitHub workflow path; only the trigger is. The final stage step replaces the placeholder dummy collect step that pointed at the long-dead $(Build.ArtifactStagingDirectory)/app directory. The .dmg lands at the macos-installer root, while the signed-and-notarized .pkg lives somewhere under disk-image/: ESRP's MacAppNotarize op repacks its output zip to wrap the notarized .pkg in a UUID-named .zip.unzipped/ subdirectory, so depending on whether notarization ran the .pkg ends up either directly under disk-image/ or at disk-image/.zip.unzipped/git-...pkg. We locate it with `find` and move it (along with the globbed .dmg) into $(Build.ArtifactStagingDirectory)/_final/, which the job's templateContext.outputs already publishes as the macos_universal pipeline artifact. `set -euo pipefail` means an empty `find` result, or a missing .dmg, fails the mv loudly rather than producing a silent half-empty upload, matching the same defensive choice the Linux stage step makes. GITHUB_WORKSPACE and VERSION are exported for the same reason as the earlier macos-installer Makefile invocations: the Makefile reads them directly and ADO does not set them automatically. Assisted-by: Claude Opus 4.7 Signed-off-by: Johannes Schindelin --- .azure-pipelines/release.yml | 55 ++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 742a8d51e303ad..9c417dfa8051a8 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -217,6 +217,21 @@ extends: - output: pipelineArtifact targetPath: '$(Build.ArtifactStagingDirectory)/_final' artifactName: '${{ dim.id }}' + # macOS build flow: + # + # 1. Configure for a universal build and produce + # Git's own dist tarballs (`make dist dist-doc`). + # 2. Extract the source tarball into payload/, copy + # config.mak in, run `make payload` to compile and + # install into stage/git-universal-/. + # 3. Mirror stage/ into build-artifacts/ (which is + # what the macos-installer Makefile's `pkg` target + # consumes - see note in the build step). + # 4. ESRP-sign Mach-O files in build-artifacts/. + # 5. `make pkg` -> unsigned .pkg in disk-image/. + # 6. ESRP-sign and ESRP-notarize the .pkg in place. + # 7. `make image` wraps disk-image/ contents in a DMG. + # 8. Stage the .pkg and .dmg under _final/ for upload. steps: - checkout: self - task: Bash@3 @@ -518,11 +533,41 @@ extends: } } ] - # 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' + - task: Bash@3 + displayName: 'Build DMG' + env: + GITHUB_WORKSPACE: $(Build.SourcesDirectory) + inputs: + targetType: inline + script: | + set -euo pipefail + + VERSION="$(git_version)" + export VERSION + + # Builds .github/macos-installer/git--universal.dmg + # from the contents of disk-image/, which now contains + # the signed and notarized .pkg. + make -C .github/macos-installer V=1 image + - task: Bash@3 + displayName: 'Stage installer artifacts for upload' + inputs: + targetType: inline + script: | + set -euo pipefail + + mkdir -p "$(Build.ArtifactStagingDirectory)/_final" + ls -la .github/macos-installer/ \ + .github/macos-installer/disk-image/ || true + # The .pkg lands either directly under disk-image/ + # or, after ESRP MacAppNotarize re-packs it, inside + # disk-image/.zip.unzipped/. Find it. + pkg=$(find .github/macos-installer/disk-image \ + -name 'git-*-universal.pkg' -type f \ + | head -1) + mv .github/macos-installer/git-*-universal.dmg \ + "$pkg" \ + "$(Build.ArtifactStagingDirectory)/_final/" # # Linux build jobs From 6870ac397d602a76b419f9003dbbaa957759de02 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Fri, 1 May 2026 12:57:46 +0100 Subject: [PATCH 14/23] azure-pipelines: add Windows path conversion utilities Add a small bash helper, .azure-pipelines/scripts/windows/utils.sh, that exposes `to_windows_path` and `to_unix_path` functions for scripts running on Windows agents. Both functions prefer `cygpath` when it is available on PATH (the full Git for Windows SDK provides it), and fall back to a small pure-shell parser otherwise. The fallback matters because some of our bash steps run before the SDK is bootstrapped and only have MinGit's bash available, which does not ship cygpath. The shared helper avoids duplicating the conversion logic across the SDK-setup script (which runs before the SDK is in place) and the ESRP-sign script (which has to hand Windows paths to ESRPClient.exe); both consume it via `. utils.sh`. --- .azure-pipelines/scripts/windows/utils.sh | 66 +++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100755 .azure-pipelines/scripts/windows/utils.sh diff --git a/.azure-pipelines/scripts/windows/utils.sh b/.azure-pipelines/scripts/windows/utils.sh new file mode 100755 index 00000000000000..f94c380a2b49af --- /dev/null +++ b/.azure-pipelines/scripts/windows/utils.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Utilities for bash scripts running on Windows. +# +# Source this file from another bash script: +# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# . "$SCRIPT_DIR/utils.sh" +# +# Functions: +# to_windows_path - output a Windows-form (D:\foo) path. +# to_unix_path - output an MSYS-form (/d/foo) path. + +# Convert a path to Windows form for tools that demand backslashes +# (e.g. ESRPClient.exe, ##vso[task.prependpath]). +# Useful when a script may run before the full Git for Windows SDK +# (which provides cygpath) is available. Falls back to pure-shell +# parsing when cygpath is not on PATH. +to_windows_path () { + local drive rest root + if command -v cygpath >/dev/null 2>&1; then + cygpath -w "$1" + return + fi + case "$1" in + /[A-Za-z]/*) + # /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 MSYS root + root=$(cd / && pwd -W) + echo "${root}${1}" | sed 's|/|\\|g' + ;; + *) + # Relative or already-Windows: just flip slashes + echo "$1" | sed 's|/|\\|g' + ;; + esac +} + +# Convert a path to MSYS form for bash-friendly handling. Inverse of +# to_windows_path. +# Useful when a script may run before the full Git for Windows SDK +# (which provides cygpath) is available. Falls back to pure-shell +# parsing when cygpath is not on PATH. +to_unix_path () { + local p drive rest + if command -v cygpath >/dev/null 2>&1; then + cygpath -u "$1" + return + fi + # Normalize separators to forward slashes first. + p="${1//\\//}" + case "$p" in + [A-Za-z]:/*) + # D:/path -> /d/path + drive=$(echo "$p" | cut -c1 | tr 'A-Z' 'a-z') + rest=$(echo "$p" | cut -c3-) + echo "/${drive}${rest}" + ;; + *) + echo "$p" + ;; + esac +} From e181dbae0ec41ca488dc6c1a058800709a7a79c4 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Fri, 1 May 2026 09:55:23 +0200 Subject: [PATCH 15/23] azure-pipelines: bootstrap Git for Windows SDK on Windows agents GitHub Actions has the git-for-windows/setup-git-for-windows-sdk@v1 action that drops a full SDK onto the runner; ADO has no equivalent task, so the Windows job has to bootstrap the SDK by hand before it can run any of the bash-driven build steps the GitHub workflow relies on. The bootstrap is driven by .azure-pipelines/scripts/windows/setup-git-sdk.sh which the Bash@3 task invokes via `filePath:` (so it runs under the agent's MinGit-provided bash). Add a `sdk_repo` field to each windows_matrix entry (git-for-windows/git-sdk-64 for x64, git-sdk-arm64 for ARM64) so the script can pick the right upstream; the field is forwarded as the script's first argument together with the matching $(mingwprefix) (mingw64 / clangarm64) and a per-job output directory under $(Agent.TempDirectory). setup-git-sdk.sh does a partial+bare clone of the SDK repository under BOOTSTRAP_DIR (defaulted to $(Build.SourcesDirectory) by the task env), then a shallow clone of git-for-windows/build-extra into a sibling directory, then runs `please.sh create-sdk-artifact --sdk= --out= build-installers` to materialise the build-installers flavour of the SDK at the requested output path. Routing through `please.sh create-sdk-artifact` means the bytes land via plain GitHub HTTPS clones (which 1ES allows) rather than the raw and release-asset CDNs that an earlier download-the-snapshot approach hit. To support agents whose only bash is the agent's bundled MinGit (cygpath is not part of MinGit, only of the full SDK), the script sources .azure-pipelines/scripts/windows/utils.sh and uses its to_unix_path / to_windows_path helpers instead of cygpath directly. Once setup-git-sdk.sh has run, the materialised SDK's own usr/bin (which does ship cygpath) and the matching MinGW toolchain bin/ are exposed to subsequent tasks via Assisted-by: Claude Opus 4.7 Signed-off-by: Johannes Schindelin --- .azure-pipelines/release.yml | 10 +++ .../scripts/windows/setup-git-sdk.sh | 74 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100755 .azure-pipelines/scripts/windows/setup-git-sdk.sh diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 9c417dfa8051a8..78c422563e0e08 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -35,6 +35,7 @@ parameters: os: windows toolchain: x86_64 mingwprefix: mingw64 + sdk_repo: git-for-windows/git-sdk-64 - id: windows_arm64 jobName: 'Windows (ARM64)' @@ -44,6 +45,7 @@ parameters: os: windows toolchain: clang-aarch64 mingwprefix: clangarm64 + sdk_repo: git-for-windows/git-sdk-arm64 - name: macos_matrix type: object @@ -143,6 +145,7 @@ extends: git_version: $[stageDependencies.prereqs.prebuild.outputs['info.git_version']] toolchain: ${{ dim.toolchain }} mingwprefix: ${{ dim.mingwprefix }} + sdk_repo: ${{ dim.sdk_repo }} templateContext: outputs: - output: pipelineArtifact @@ -155,6 +158,13 @@ extends: displayName: 'Add Git Bash to PATH' inputs: filename: ./.azure-pipelines/scripts/windows/setup-git-bash.cmd + - task: Bash@3 + displayName: 'Install Git for Windows SDK' + inputs: + filePath: ./.azure-pipelines/scripts/windows/setup-git-sdk.sh + arguments: '$(sdk_repo) $(mingwprefix) "$(Agent.TempDirectory)\gitsdk"' + env: + BOOTSTRAP_DIR: '$(Build.SourcesDirectory)' # Setup ESRP code signing for Windows (sets ESRP_TOOL, ESRP_AUTH) - ${{ if eq(parameters.esrp, true) }}: - template: .azure-pipelines/esrp/windows/setup.yml@self diff --git a/.azure-pipelines/scripts/windows/setup-git-sdk.sh b/.azure-pipelines/scripts/windows/setup-git-sdk.sh new file mode 100755 index 00000000000000..c123ec3682a9b5 --- /dev/null +++ b/.azure-pipelines/scripts/windows/setup-git-sdk.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Materialise the build-installers flavour of the Git for Windows SDK. +# +# Performs a partial + bare clone of the given Git SDK repository, +# then runs build-extra's please.sh to sparse-checkout just the +# build-installers subset into the requested SDK output directory. +# +# Environment: +# BOOTSTRAP_DIR (optional) - directory for transient bootstrap clones +# (the bare git-sdk fetch and build-extra +# checkout used to drive please.sh). +# Falls back to TEMP, then TMP, then errors +# if none are set. +# +# Arguments: +# $1 sdk_repo e.g. git-for-windows/git-sdk-64 +# $2 mingwprefix e.g. mingw64 or clangarm64 +# $3 sdk_output_dir Windows or MSYS path where the SDK will be installed +# +# See: +# https://github.com/git-for-windows/git-sdk-64/blob/main/.github/workflows/ci-artifacts.yml +# https://github.com/git-for-windows/build-extra/blob/main/please.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$SCRIPT_DIR/utils.sh" + +if test $# -ne 3 +then + echo "Usage: $0 " >&2 + exit 1 +fi + +sdk_repo="$1" +mingwprefix="$2" +sdk_output="$3" + +bootstrap_dir="${BOOTSTRAP_DIR:-${TEMP:-${TMP:-}}}" +if test -z "$bootstrap_dir" +then + echo "BOOTSTRAP_DIR (or TEMP/TMP) must be set" >&2 + exit 1 +fi + +bootstrap="$(to_unix_path "$bootstrap_dir")" +sdk="$(to_unix_path "$sdk_output")" + +sdk_bare="$bootstrap/sdk-bare.git" +bootstrap_be="$bootstrap/build-extra-bootstrap" + +git init --bare "$sdk_bare" +git --git-dir="$sdk_bare" remote add origin "https://github.com/$sdk_repo" +git --git-dir="$sdk_bare" config remote.origin.promisor true +git --git-dir="$sdk_bare" config remote.origin.partialCloneFilter blob:none +git --git-dir="$sdk_bare" fetch --depth=1 origin HEAD +git --git-dir="$sdk_bare" update-ref --no-deref HEAD FETCH_HEAD + +# please.sh is the bootstrap; build-extra gets cloned again into the SDK +# in a separate task so `please.sh build-mingw-w64-git` can find it at +# /usr/src/build-extra under the SDK's bash. +git clone --depth=1 --single-branch -b main \ + https://github.com/git-for-windows/build-extra \ + "$bootstrap_be" + +# Architecture is auto-detected from the bare clone's HEAD tree +# (clangarm64/ vs usr/x86_64-pc-msys/). +bash "$bootstrap_be/please.sh" create-sdk-artifact \ + --sdk="$sdk_bare" --out="$sdk" build-installers + +# Expose the SDK's bash and the matching MinGW toolchain to subsequent +# tasks. +echo "##vso[task.prependpath]$(to_windows_path "$sdk/usr/bin")" +echo "##vso[task.prependpath]$(to_windows_path "$sdk/$mingwprefix/bin")" From 1add9779ebd34cc91d782bd2f90ff2e9d08474e1 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 30 Apr 2026 12:33:24 +0100 Subject: [PATCH 16/23] 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 78c422563e0e08..6e7f9a10004d11 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -158,6 +158,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 (x64 on ARM64)' - task: Bash@3 displayName: 'Install Git for Windows SDK' inputs: From 2427baf6cca64fb2e3daf4bdfeabdb1265affd74 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Fri, 1 May 2026 10:00:42 +0200 Subject: [PATCH 17/23] azure-pipelines: clone build-extra into Windows SDK Git for Windows' build tooling (please.sh, signtool.sh, the installer .iss templates, and the MINGW-packages helpers) lives in git-for-windows/build-extra rather than in the SDK snapshot. The GitHub workflow's Windows job clones it into /usr/src/build-extra of the SDK before invoking please.sh; mirror that here. A partial clone (--filter=blob:none) plus --single-branch -b main is enough for everything please.sh needs and avoids pulling the full blob history; it matches the workflow's invocation byte for byte. The bash task picks up the SDK's bash from the PATH set up by the preceding bootstrap step, so /usr/src resolves into the SDK at C:\sdk\usr\src as expected. Assisted-by: Claude Opus 4.7 Signed-off-by: Johannes Schindelin --- .azure-pipelines/release.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 6e7f9a10004d11..39cd43ba35ebe6 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -178,6 +178,18 @@ extends: arguments: '$(sdk_repo) $(mingwprefix) "$(Agent.TempDirectory)\gitsdk"' env: BOOTSTRAP_DIR: '$(Build.SourcesDirectory)' + - task: Bash@3 + displayName: 'Clone build-extra into SDK' + inputs: + targetType: inline + script: | + set -euo pipefail + # The please.sh + signtool.sh scripts the build + # relies on live in build-extra; the SDK ships + # without them. Partial clone to keep this fast. + git clone --filter=blob:none --single-branch -b main \ + https://github.com/git-for-windows/build-extra \ + /usr/src/build-extra # 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 29d76fa0bcaf519c44c5898685aad5f4bcb7ae2e Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Fri, 1 May 2026 10:03:36 +0200 Subject: [PATCH 18/23] azure-pipelines: build mingw-w64-git package on Windows Drive please.sh build-mingw-w64-git from a Bash@3 task using the SDK's bash that the bootstrap step put on the PATH. Outputs land in $(Build.SourcesDirectory)/artifacts/ so the subsequent installer-build step can pass them to please.sh make_installers_from_mingw_w64_git via --pkg= flags. Three small adaptations from the GitHub workflow's source step: The /usr/bin/git trampoline that delegates to the matching MinGW-built git.exe is the same one the workflow writes by hand; makepkg-mingw shells out to plain `git`, and the SDK bash's git candidates would otherwise come from MinGit, not the toolchain we are building against. The user.name / user.email / PACKAGER values used to be the GitHub actor; on ADO there is no equivalent identity, so the initial port hardcodes a build-bot identity. If this needs to attribute to a specific human or service principal later, that is a one-line change here. please.sh's --only- flag takes the bare CPU name (x86_64 or aarch64), not the toolchain triple, so a `cpu_arch` matrix dimension surfaces the right value next to each toolchain entry. Adding another arch later would extend the matrix rather than touch the build script. The task detaches stdin via `exec snapshot that microsoft/git does not currently ship. Assisted-by: Claude Opus 4.7 Signed-off-by: Johannes Schindelin --- .azure-pipelines/release.yml | 46 ++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 39cd43ba35ebe6..10d69f9a0fad24 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -34,6 +34,7 @@ parameters: image: win-x86_64-ado1es os: windows toolchain: x86_64 + cpu_arch: x86_64 mingwprefix: mingw64 sdk_repo: git-for-windows/git-sdk-64 @@ -44,6 +45,7 @@ parameters: image: win-arm64-ado1es os: windows toolchain: clang-aarch64 + cpu_arch: aarch64 mingwprefix: clangarm64 sdk_repo: git-for-windows/git-sdk-arm64 @@ -146,6 +148,7 @@ extends: toolchain: ${{ dim.toolchain }} mingwprefix: ${{ dim.mingwprefix }} sdk_repo: ${{ dim.sdk_repo }} + cpu_arch: ${{ dim.cpu_arch }} templateContext: outputs: - output: pipelineArtifact @@ -190,6 +193,49 @@ extends: git clone --filter=blob:none --single-branch -b main \ https://github.com/git-for-windows/build-extra \ /usr/src/build-extra + - task: Bash@3 + displayName: 'Build mingw-w64-git package' + inputs: + targetType: inline + script: | + set -euo pipefail + set -x + + # Detach stdin so descendants like the git-extra + # post_install hook (which runs `for s in $(grep + # -l PAT $(find /mingw*/bin/ ...))` and falls back + # to reading stdin when /mingw*/bin/ is absent + # and find produces empty output) cannot block + # the build waiting for input. Bash@3 leaves the + # task's stdin pipe open with no writer; the + # GitHub Actions runner closes it for the same + # reason (see actions/runner ProcessInvoker.cs). + exec /usr/bin/git + chmod +x /usr/bin/git + + USER_NAME='microsoft-git-build' + USER_EMAIL='microsoft-git-build@users.noreply.github.com' + git config --global user.name "$USER_NAME" + git config --global user.email "$USER_EMAIL" + export PACKAGER="$USER_NAME <$USER_EMAIL>" + + sh -x /usr/src/build-extra/please.sh build-mingw-w64-git \ + --only-"$(cpu_arch)" \ + --build-src-pkg \ + -o artifacts \ + HEAD + + # NOTE: the GitHub workflow additionally GPG-signs + # each tarball and creates a MINGW-packages.bundle + # for downstream Pacman consumers; both are + # intentionally out of scope for the initial port + # and tracked as follow-ups. # 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 ffbbe68ee5645b15c554d8915da017dd9d8b5089 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Fri, 1 May 2026 10:07:40 +0200 Subject: [PATCH 19/23] azure-pipelines: build Windows installer and portable Git Drive please.sh make_installers_from_mingw_w64_git for both installer and portable variants from a single bash task. The GitHub workflow runs these as separate matrix jobs (one per type/arch combination); the AzP version keeps both builds in the same job so the .pkg.tar.* artifacts produced in the previous step are available without an inter-job artifact passing trip. The PDB archive copy into build-extra/cached-source-packages is the same prerequisite that --include-pdbs needs in the GitHub workflow. Build-extra reads from there to embed PDBs into the installer; without the copy --include-pdbs is silently ineffective. The --pkg= filter that strips signatures and the optional archimport / cvs / p4 / gitweb / doc-man pieces matches the workflow's sed exactly so the resulting .exe sizes are comparable. The task detaches stdin via `exec --- .azure-pipelines/release.yml | 53 ++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 10d69f9a0fad24..c26f1dfe510016 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -236,6 +236,59 @@ extends: # for downstream Pacman consumers; both are # intentionally out of scope for the initial port # and tracked as follow-ups. + - task: Bash@3 + displayName: 'Build installer and portable Git' + inputs: + targetType: inline + script: | + set -euo pipefail + set -x + + # Detach stdin so descendants like the git-extra + # post_install hook (which runs `for s in $(grep + # -l PAT $(find /mingw*/bin/ ...))` and falls back + # to reading stdin when /mingw*/bin/ is absent + # and find produces empty output) cannot block + # the build waiting for input. Bash@3 leaves the + # task's stdin pipe open with no writer; the + # GitHub Actions runner closes it for the same + # reason (see actions/runner ProcessInvoker.cs). + exec /tmp/setx.sh + export BASH_ENV=/tmp/setx.sh + + # please.sh make_installers_from_mingw_w64_git + # --include-pdbs reads PDB archives from + # cached-source-packages/. + mkdir -p "$b/cached-source-packages" + cp artifacts/*-pdb* "$b/cached-source-packages/" + + # The --pkg=... list excludes the optional pieces + # the workflow drops (signatures, archimport, cvs, + # p4, gitweb, doc-man); keep the same filter so + # the resulting .exe size is comparable. + pkg_args=$( + ls artifacts/mingw-w64-$(toolchain)-*.tar.* \ + | sed '/\.sig$/d;/archimport/d;/cvs/d;/p4/d;/gitweb/d;/doc-man/d;s/^/--pkg=/' \ + | tr '\n' ' ' + ) + + for type in installer portable; do + eval sh -x "$b"/please.sh make_installers_from_mingw_w64_git --include-pdbs \ + --version="$(git_version)" \ + -o artifacts --"$type" \ + $pkg_args + done # 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 76f68473e69e0e0e2bd6d41dfacabe31ac54d81a Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Fri, 1 May 2026 15:54:57 +0100 Subject: [PATCH 20/23] azure-pipelines: customise the Windows installer via patches The GitHub Actions workflow at .github/workflows/build-git-installers.yml applies five `sed` transformations during the Windows build to turn upstream Git for Windows into the microsoft/git distribution. They are spread over five run: blocks and largely opaque without following each sed pattern by hand. Capture them as patches instead. They are produced by running each `sed` against a clean checkout of the upstream tree and recording `git diff`, so they apply with `patch -p1` against the same trees the build flow already provides. Patches live under .azure-pipelines/patches/windows/, grouped by the upstream tree they mutate: build-extra/ 0000-installer-publisher.patch AppPublisher = The Git Client Team at Microsoft. 0001-installer-vsintegration.patch Add CustomPostInstall/Uninstall hooks that register the install path for Visual Studio's TeamFoundation Git integration; append the procedure bodies to helpers.inc.iss. 0002-installer-default-components.patch Pre-select the autoupdate (and Scalar, when WITH_SCALAR is defined) components when the user passes no /COMPONENTS override. 0003-installer-fork-from-microsoft-git.patch Ship a default git-update-git-for-windows.config that points the auto-updater at microsoft/git instead of upstream Git for Windows, and register it via Source: / Type: lines that use the existing {#MINGW_BITNESS} Inno Setup macro so the same patch works for both x64 and ARM64. git-sdk/ 0000-update-recently-seen.patch Set use_recently_seen=no in the SDK's git-update-git-for-windows helper so update prompts continue until the user takes the upgrade. Path inside the patch is bin/git-update-git-for-windows so the same patch applies to either /mingw64 (git-sdk-64) or /clangarm64 (git-sdk-arm64) at apply time. A small helper, .azure-pipelines/scripts/apply-patches.sh, applies every *.patch file in a directory in lexicographic order via `patch -p1`. We use patch(1) rather than `git apply` because the latter is strict about context whitespace; CRLF/LF mismatches between the patch context (as authored) and the working tree (which may be CRLF on Windows checkouts) trip it up. patch is more forgiving by default, and matches the convention used by msys2/MINGW-packages PKGBUILDs and git-for-windows/build-extra's get-sources.sh. A new 'Apply Windows build patches' Bash@3 task runs the script twice - once for build-extra at /usr/src/build-extra, once for the SDK at /$(mingwprefix) - between cloning build-extra and building the mingw-w64-git package, mirroring where the GitHub workflow's sed steps slot in. --- .../0000-installer-publisher.patch | 13 +++++ .../0001-installer-vsintegration.patch | 50 +++++++++++++++++ .../0002-installer-default-components.patch | 17 ++++++ ...03-installer-fork-from-microsoft-git.patch | 29 ++++++++++ .../git-sdk/0000-update-recently-seen.patch | 12 ++++ .azure-pipelines/release.yml | 10 ++++ .azure-pipelines/scripts/apply-patches.sh | 56 +++++++++++++++++++ 7 files changed, 187 insertions(+) create mode 100644 .azure-pipelines/patches/windows/build-extra/0000-installer-publisher.patch create mode 100644 .azure-pipelines/patches/windows/build-extra/0001-installer-vsintegration.patch create mode 100644 .azure-pipelines/patches/windows/build-extra/0002-installer-default-components.patch create mode 100644 .azure-pipelines/patches/windows/build-extra/0003-installer-fork-from-microsoft-git.patch create mode 100644 .azure-pipelines/patches/windows/git-sdk/0000-update-recently-seen.patch create mode 100755 .azure-pipelines/scripts/apply-patches.sh diff --git a/.azure-pipelines/patches/windows/build-extra/0000-installer-publisher.patch b/.azure-pipelines/patches/windows/build-extra/0000-installer-publisher.patch new file mode 100644 index 00000000000000..aaef8b8a35b6d1 --- /dev/null +++ b/.azure-pipelines/patches/windows/build-extra/0000-installer-publisher.patch @@ -0,0 +1,13 @@ +diff --git a/installer/install.iss b/installer/install.iss +index 70787b7..137f660 100644 +--- a/installer/install.iss ++++ b/installer/install.iss +@@ -65,7 +65,7 @@ SignTool=signtool + ; Installer-related + AllowNoIcons=yes + AppName={#APP_NAME} +-AppPublisher=The Git Development Community ++AppPublisher=The Git Client Team at Microsoft + AppPublisherURL={#APP_URL} + AppSupportURL={#APP_CONTACT_URL} + AppVersion={#APP_VERSION} diff --git a/.azure-pipelines/patches/windows/build-extra/0001-installer-vsintegration.patch b/.azure-pipelines/patches/windows/build-extra/0001-installer-vsintegration.patch new file mode 100644 index 00000000000000..6797e6ab6c88ba --- /dev/null +++ b/.azure-pipelines/patches/windows/build-extra/0001-installer-vsintegration.patch @@ -0,0 +1,50 @@ +diff --git a/installer/helpers.inc.iss b/installer/helpers.inc.iss +index 3e3788d..fc81be7 100644 +--- a/installer/helpers.inc.iss ++++ b/installer/helpers.inc.iss +@@ -224,3 +224,25 @@ begin + DeleteFile(OutPath); + DeleteFile(ErrPath); + end; ++ ++procedure CustomPostInstall(); ++begin ++ if not RegWriteStringValue(HKEY_CURRENT_USER,'Software\Microsoft\VSCommon\15.0\TeamFoundation\GitSourceControl','GitPath',ExpandConstant('{app}')) or ++ not RegWriteStringValue(HKEY_CURRENT_USER,'Software\Microsoft\VSCommon\16.0\TeamFoundation\GitSourceControl','GitPath',ExpandConstant('{app}')) or ++ not RegWriteStringValue(HKEY_CURRENT_USER,'Software\Microsoft\VSCommon\17.0\TeamFoundation\GitSourceControl','GitPath',ExpandConstant('{app}')) or ++ not RegWriteStringValue(HKEY_CURRENT_USER,'Software\Microsoft\VSCommon\18.0\TeamFoundation\GitSourceControl','GitPath',ExpandConstant('{app}')) or ++ not RegWriteStringValue(HKEY_CURRENT_USER,'Software\Microsoft\VSCommon\19.0\TeamFoundation\GitSourceControl','GitPath',ExpandConstant('{app}')) or ++ not RegWriteStringValue(HKEY_CURRENT_USER,'Software\Microsoft\VSCommon\20.0\TeamFoundation\GitSourceControl','GitPath',ExpandConstant('{app}')) then ++ LogError('Could not register TeamFoundation\GitSourceControl'); ++end; ++ ++procedure CustomPostUninstall(); ++begin ++ if not RegDeleteValue(HKEY_CURRENT_USER,'Software\Microsoft\VSCommon\15.0\TeamFoundation\GitSourceControl','GitPath') or ++ not RegDeleteValue(HKEY_CURRENT_USER,'Software\Microsoft\VSCommon\16.0\TeamFoundation\GitSourceControl','GitPath') or ++ not RegDeleteValue(HKEY_CURRENT_USER,'Software\Microsoft\VSCommon\17.0\TeamFoundation\GitSourceControl','GitPath') or ++ not RegDeleteValue(HKEY_CURRENT_USER,'Software\Microsoft\VSCommon\18.0\TeamFoundation\GitSourceControl','GitPath') or ++ not RegDeleteValue(HKEY_CURRENT_USER,'Software\Microsoft\VSCommon\19.0\TeamFoundation\GitSourceControl','GitPath') or ++ not RegDeleteValue(HKEY_CURRENT_USER,'Software\Microsoft\VSCommon\20.0\TeamFoundation\GitSourceControl','GitPath') then ++ LogError('Could not register TeamFoundation\GitSourceControl'); ++end; +diff --git a/installer/install.iss b/installer/install.iss +index 70787b7..74d8375 100644 +--- a/installer/install.iss ++++ b/installer/install.iss +@@ -3603,6 +3603,7 @@ begin + Install a scheduled task to try to auto-update Git for Windows + } + ++ CustomPostInstall(); + if IsComponentInstalled('autoupdate') then begin + WizardForm.StatusLabel.Caption:='Set up daily up to date check'; + InstallAutoUpdater(); +@@ -3943,6 +3944,7 @@ begin + Remove the scheduled task to try to auto-update Git for Windows + } + ++ CustomPostUninstall(); + if IsComponentInstalled('autoupdate') then + UninstallAutoUpdater(); + diff --git a/.azure-pipelines/patches/windows/build-extra/0002-installer-default-components.patch b/.azure-pipelines/patches/windows/build-extra/0002-installer-default-components.patch new file mode 100644 index 00000000000000..2135ecf925bc84 --- /dev/null +++ b/.azure-pipelines/patches/windows/build-extra/0002-installer-default-components.patch @@ -0,0 +1,17 @@ +diff --git a/installer/install.iss b/installer/install.iss +index 70787b7..37d79b0 100644 +--- a/installer/install.iss ++++ b/installer/install.iss +@@ -1925,6 +1925,12 @@ begin + GetDefaultsFromGitConfig('system'); + + ChosenOptions:=''; ++ if (ExpandConstant('{param:components|/}')='/') then begin ++ WizardSelectComponents('autoupdate'); ++#ifdef WITH_SCALAR ++ WizardSelectComponents('scalar'); ++#endif ++ end; + + PrevPageID:=wpSelectProgramGroup; + diff --git a/.azure-pipelines/patches/windows/build-extra/0003-installer-fork-from-microsoft-git.patch b/.azure-pipelines/patches/windows/build-extra/0003-installer-fork-from-microsoft-git.patch new file mode 100644 index 00000000000000..28af996716dc03 --- /dev/null +++ b/.azure-pipelines/patches/windows/build-extra/0003-installer-fork-from-microsoft-git.patch @@ -0,0 +1,29 @@ +diff --git a/git-update-git-for-windows.config b/git-update-git-for-windows.config +new file mode 100644 +index 0000000..bfd0744 +--- /dev/null ++++ b/git-update-git-for-windows.config +@@ -0,0 +1,2 @@ ++[update] ++ fromFork = microsoft/git +diff --git a/installer/install.iss b/installer/install.iss +index 70787b7..71d5e72 100644 +--- a/installer/install.iss ++++ b/installer/install.iss +@@ -126,6 +126,7 @@ Filename: {app}\ReleaseNotes.html; Description: View Release Notes; Flags: shell + [Files] + ; Install files that might be in use during setup under a different name. + #include "file-list.iss" ++Source: {#SourcePath}\..\git-update-git-for-windows.config; DestDir: {app}\{#MINGW_BITNESS}\bin; Flags: replacesameversion; AfterInstall: DeleteFromVirtualStore + Source: {#SourcePath}\ReleaseNotes.html; DestDir: {app}; Flags: replacesameversion; AfterInstall: DeleteFromVirtualStore + Source: {#SourcePath}\..\LICENSE.txt; DestDir: {app}; Flags: replacesameversion; AfterInstall: DeleteFromVirtualStore + Source: {#SourcePath}\NOTICE.txt; DestDir: {app}; Flags: replacesameversion; AfterInstall: DeleteFromVirtualStore; Check: ParamIsSet('VSNOTICE') +@@ -275,6 +276,8 @@ Type: files; Name: {app}\etc\rebase.db.i386 + Type: files; Name: {app}\etc\install-options.txt + Type: dirifempty; Name: {app}\{#MINGW_BITNESS}\libexec\git-core + Type: dirifempty; Name: {app}\{#MINGW_BITNESS}\libexec ++Type: files; Name: {app}\{#MINGW_BITNESS}\bin\git-update-git-for-windows.config ++Type: dirifempty; Name: {app}\{#MINGW_BITNESS}\bin + Type: dirifempty; Name: {app}\{#MINGW_BITNESS} + Type: dirifempty; Name: {app} + diff --git a/.azure-pipelines/patches/windows/git-sdk/0000-update-recently-seen.patch b/.azure-pipelines/patches/windows/git-sdk/0000-update-recently-seen.patch new file mode 100644 index 00000000000000..cf73dfd5f3f449 --- /dev/null +++ b/.azure-pipelines/patches/windows/git-sdk/0000-update-recently-seen.patch @@ -0,0 +1,12 @@ +diff --git a/bin/git-update-git-for-windows b/bin/git-update-git-for-windows +index 29444d9..6705da1 100644 +--- a/bin/git-update-git-for-windows ++++ b/bin/git-update-git-for-windows +@@ -4,6 +4,7 @@ + # release. If versions differ, the bit matched installer is downloaded and run + # when confirmation to do so is given. + ++use_recently_seen=no + + # Compare version strings + # Prints -1, 0 or 1 to stdout diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index c26f1dfe510016..2b6bc1159d68a4 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -193,6 +193,16 @@ extends: git clone --filter=blob:none --single-branch -b main \ https://github.com/git-for-windows/build-extra \ /usr/src/build-extra + - task: Bash@3 + displayName: 'Apply Windows build patches' + inputs: + targetType: inline + script: | + set -euo pipefail + apply="$(cygpath -au "$BUILD_SOURCESDIRECTORY/.azure-pipelines/scripts/apply-patches.sh")" + patches="$(cygpath -au "$BUILD_SOURCESDIRECTORY/.azure-pipelines/patches/windows")" + bash "$apply" "$patches/build-extra" /usr/src/build-extra + bash "$apply" "$patches/git-sdk" "/$(mingwprefix)" - task: Bash@3 displayName: 'Build mingw-w64-git package' inputs: diff --git a/.azure-pipelines/scripts/apply-patches.sh b/.azure-pipelines/scripts/apply-patches.sh new file mode 100755 index 00000000000000..f1325a7caa0064 --- /dev/null +++ b/.azure-pipelines/scripts/apply-patches.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Apply all numbered patches from a directory to a target tree. +# +# Patches are applied in lexicographic order, so name them with +# zero-padded numeric prefixes (e.g. 0000-foo.patch, 0001-bar.patch). +# +# Arguments: +# $1 patches_dir Directory containing *.patch files +# $2 target_dir Directory to apply patches in (need not be a +# git repository; git apply works on any tree) + +set -euo pipefail + +if test $# -ne 2 +then + echo "Usage: $0 " >&2 + exit 1 +fi + +patches_dir="$1" +target_dir="$2" + +if test ! -d "$patches_dir" +then + echo "Patches directory not found: $patches_dir" >&2 + exit 1 +fi + +if test ! -d "$target_dir" +then + echo "Target directory not found: $target_dir" >&2 + exit 1 +fi + +shopt -s nullglob +patches=("$patches_dir"/*.patch) +if test ${#patches[@]} -eq 0 +then + echo "No patches found in $patches_dir" + exit 0 +fi + +cd "$target_dir" +for patch in "${patches[@]}" +do + echo "Applying $(basename "$patch")..." + # Use patch(1) rather than `git apply` because the latter is + # strict about context whitespace; CRLF/LF mismatches between + # patch context (as authored) and the working tree (which may + # be CRLF on Windows checkouts) trip it up. patch is more + # forgiving by default. + # + # This matches the convention used by msys2/MINGW-packages + # PKGBUILDs and git-for-windows/build-extra's get-sources.sh. + command patch -p1 -i "$patch" +done From 407f744aaf6524311cec6daf45baa96e902f2df1 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Fri, 1 May 2026 10:14:25 +0200 Subject: [PATCH 21/23] azure-pipelines: stage Windows installer artifacts for upload Replace the dummy build / "Example ESRP signing" / "Dummy collect artifacts" placeholders that the initial pipeline scaffolding left behind with a real staging step that ships the actual Windows installer artifacts produced by the previous commits. The dummy build copied calc.exe into example1/2/3.exe, the dummy ESRP step signed those fakes, and the dummy collect xcopy'd them into _final/. With the real mingw-w64-git build, installer build, and customise-installer-via-patches commits in place, all three placeholders are obsolete. The new 'Stage installer artifacts for upload' Bash@3 task computes a SHA-256 sidecar over Git-*.exe and PortableGit-*.exe and copies all three (the two installers plus sha-256.txt) into $(Build.ArtifactStagingDirectory)/_final/, which the job's templateContext.outputs.pipelineArtifact already publishes as the windows_x64 / windows_arm64 artifact. The SHA-256 sidecar lives here, post-build, rather than in the build step because ESRP signing (added in the next commit) rewrites the .exe contents; a SHA-256 computed before signing would mismatch the bytes that ship. The .exes ship unsigned at this point in the series. The next commit wires up ESRP signing through Git for Windows' signtool alias mechanism so each individual binary inside the installer gets signed during the build, not just the outer .exe wrapper. Assisted-by: Claude Opus 4.7 Signed-off-by: Johannes Schindelin --- .azure-pipelines/release.yml | 55 ++++++++++++++---------------------- 1 file changed, 21 insertions(+), 34 deletions(-) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 2b6bc1159d68a4..fb63f1b08848a5 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -307,40 +307,27 @@ extends: 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: | - 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' - # - # 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 - xcopy /s /y $(Build.ArtifactStagingDirectory)\app $(Build.ArtifactStagingDirectory)\_final - displayName: 'Dummy collect artifacts' + - task: Bash@3 + displayName: 'Stage installer artifacts for upload' + inputs: + targetType: inline + script: | + set -euo pipefail + + # Compute SHA-256 over the (possibly signed) + # binaries; if ESRP signing ran, this picks up + # the post-sign bytes, which is what we want to + # publish in the release notes. + openssl dgst -sha256 \ + artifacts/Git-*.exe \ + artifacts/PortableGit-*.exe \ + | sed 's/.* //' >artifacts/sha-256.txt + + mkdir -p "$(Build.ArtifactStagingDirectory)/_final" + cp artifacts/Git-*.exe \ + artifacts/PortableGit-*.exe \ + artifacts/sha-256.txt \ + "$(Build.ArtifactStagingDirectory)/_final/" # # macOS build jobs From 87014fe323224714a426d2197031924d5cf9ade5 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Fri, 1 May 2026 14:40:15 +0100 Subject: [PATCH 22/23] azure-pipelines: ESRP-sign Windows installer artifacts via signtool alias Use build-extra's signtool-alias hook so that ESRP signs every binary that ends up in the Windows installer and portable Git, not just the outer .exe wrapper. A naive post-build sign of just Git-*.exe and PortableGit-*.exe would leave every binary embedded inside the installer (DLLs, helper exes, the mingw-w64-git pkg payload) shipping unsigned. The mechanism, set up by build-extra: - please.sh's build_mingw_w64_git checks `git config alias.signtool` and, if set, exports SIGNTOOL="git ... signtool" into makepkg-mingw so the PKGBUILD can sign individual binaries during the package build. - build-extra's installer/release.sh checks the same alias and passes //Ssigntool="git signtool $f" //DSIGNTOOL to Inno Setup's ISCC.exe, which then signs every embedded file via the SignTool=signtool directive in install.iss. The portable Git .exe is a 7z self-extractor that bypasses the Inno Setup signtool path; sign it explicitly after `make_installers` returns. Move the ESRP setup template (sets ESRP_TOOL and ESRP_AUTH) to before the build steps, add a Bash@3 task that registers the signtool alias to invoke .azure-pipelines/esrp/windows/esrpsign.sh, and add the ESRP env vars to both build tasks so esrpsign.sh has ESRP_TOOL, ESRP_AUTH, and SYSTEM_ACCESSTOKEN available when invoked via the alias from inside please.sh, makepkg-mingw, or Inno Setup. MSYSTEM lands in the build installer task's env block at the same time, because that env block only exists once the ESRP variables are added here. installer/release.sh requires MSYSTEM to select the architecture branch, and Bash@3 does not source /etc/profile, so we export it explicitly. Also drop the local `to_windows_path` copy from esrpsign.sh now that the shared utils.sh helpers are available and can be sourced directly. esrpsign.sh's calling convention (` [file ...]`, sign in place) already matches signtool.sh's, so no further script changes are needed. --- .azure-pipelines/esrp/windows/esrpsign.sh | 27 +---- .azure-pipelines/release.yml | 68 +++++++++++-- .../scripts/windows/setup-cv2pdb-x64.ps1 | 99 +++++++++++++++++++ 3 files changed, 160 insertions(+), 34 deletions(-) create mode 100644 .azure-pipelines/scripts/windows/setup-cv2pdb-x64.ps1 diff --git a/.azure-pipelines/esrp/windows/esrpsign.sh b/.azure-pipelines/esrp/windows/esrpsign.sh index a3bf1bc66ea4f8..ee2ed2db5d9e2d 100755 --- a/.azure-pipelines/esrp/windows/esrpsign.sh +++ b/.azure-pipelines/esrp/windows/esrpsign.sh @@ -38,6 +38,7 @@ if [ -z "${SYSTEM_ACCESSTOKEN:-}" ]; then fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$SCRIPT_DIR/../../scripts/windows/utils.sh" # Check for overriden key code, otherwise use default (Microsoft Third-Party/OSS) ESRP_KEYCODE="${ESRP_KEYCODE:-CP-231522}" @@ -54,32 +55,6 @@ if [ ! -f "$ESRP_TOOL" ]; then 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="" diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index fb63f1b08848a5..b1da29c2494041 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -36,6 +36,7 @@ parameters: toolchain: x86_64 cpu_arch: x86_64 mingwprefix: mingw64 + msystem: MINGW64 sdk_repo: git-for-windows/git-sdk-64 - id: windows_arm64 @@ -47,6 +48,7 @@ parameters: toolchain: clang-aarch64 cpu_arch: aarch64 mingwprefix: clangarm64 + msystem: CLANGARM64 sdk_repo: git-for-windows/git-sdk-arm64 - name: macos_matrix @@ -174,6 +176,14 @@ extends: Write-Host "##vso[task.prependpath]$azPath" Write-Host "Azure CLI installed." displayName: 'Install Azure CLI (x64 on ARM64)' + # Install VS 2022 Build Tools on x64 so cv2pdb-strip can locate + # mspdb140.dll. We do not need to do this on ARM64 since we use + # clang's llvm-strip there instead. + - ${{ if eq(dim.poolArch, 'amd64') }}: + - task: PowerShell@2 + displayName: 'Setup cv2pdb (x64)' + inputs: + filePath: ./.azure-pipelines/scripts/windows/setup-cv2pdb-x64.ps1 - task: Bash@3 displayName: 'Install Git for Windows SDK' inputs: @@ -193,6 +203,31 @@ extends: git clone --filter=blob:none --single-branch -b main \ https://github.com/git-for-windows/build-extra \ /usr/src/build-extra + # Setup ESRP code signing for Windows (sets ESRP_TOOL, + # ESRP_AUTH) before the build steps so that the build + # itself (Inno Setup, makepkg-mingw) can invoke ESRP + # via the `git signtool` alias for in-line signing of + # individual binaries. + - ${{ if eq(parameters.esrp, true) }}: + - template: .azure-pipelines/esrp/windows/setup.yml@self + parameters: + serviceConnectionName: $(esrpAppConnectionName) + esrpClientId: $(esrpClientId) + keyVaultName: $(esrpKeyVaultName) + signCertName: $(esrpSignReqCertName) + - task: Bash@3 + displayName: 'Configure git signtool alias for ESRP' + inputs: + targetType: inline + script: | + set -euo pipefail + # please.sh, makepkg-mingw, and Inno Setup's + # release.sh all detect this alias and route + # their per-file code-signing through it; see + # build-extra's please.sh + installer/release.sh. + script="$(cygpath -au "$BUILD_SOURCESDIRECTORY/.azure-pipelines/esrp/windows/esrpsign.sh")" + git config --global alias.signtool "!sh \"$script\"" + git config --global --get alias.signtool - task: Bash@3 displayName: 'Apply Windows build patches' inputs: @@ -205,6 +240,11 @@ extends: bash "$apply" "$patches/git-sdk" "/$(mingwprefix)" - task: Bash@3 displayName: 'Build mingw-w64-git package' + ${{ if eq(parameters.esrp, true) }}: + env: + ESRP_TOOL: $(ESRP_TOOL) + ESRP_AUTH: $(ESRP_AUTH) + SYSTEM_ACCESSTOKEN: $(System.AccessToken) inputs: targetType: inline script: | @@ -248,6 +288,16 @@ extends: # and tracked as follow-ups. - task: Bash@3 displayName: 'Build installer and portable Git' + env: + # `please.sh make_installers_from_mingw_w64_git` invokes + # build-extra's installer/release.sh, which requires + # MSYSTEM to select the architecture branch. Bash@3 does + # not source /etc/profile, so we export it explicitly. + MSYSTEM: ${{ dim.msystem }} + ${{ if eq(parameters.esrp, true) }}: + ESRP_TOOL: $(ESRP_TOOL) + ESRP_AUTH: $(ESRP_AUTH) + SYSTEM_ACCESSTOKEN: $(System.AccessToken) inputs: targetType: inline script: | @@ -298,15 +348,17 @@ extends: --version="$(git_version)" \ -o artifacts --"$type" \ $pkg_args + + # The installer .exe is signed inline by Inno + # Setup via the `git signtool` alias; the + # portable .exe is a 7z self-extractor that + # bypasses that path, so sign it explicitly. + if test "$type" = portable && \ + test -n "$(git config alias.signtool)" + then + git signtool artifacts/PortableGit-*.exe + fi done - # 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) - task: Bash@3 displayName: 'Stage installer artifacts for upload' inputs: diff --git a/.azure-pipelines/scripts/windows/setup-cv2pdb-x64.ps1 b/.azure-pipelines/scripts/windows/setup-cv2pdb-x64.ps1 new file mode 100644 index 00000000000000..a035f43897e4b8 --- /dev/null +++ b/.azure-pipelines/scripts/windows/setup-cv2pdb-x64.ps1 @@ -0,0 +1,99 @@ +# Set up cv2pdb-strip support on Windows x64 agents. +# +# build-extra's please.sh runs cv2pdb-strip during the strip phase of +# build-mingw-w64-git. cv2pdb-strip loads mspdb140.dll via PATH +# lookup, and the DLL is part of the MSVC C++ toolchain +# (Microsoft.VisualStudio.Component.VC.Tools.x86.x64) which is not +# present on the 1ES image by default. +# +# Install VS 2022 Build Tools with that single component (the +# smallest selection that ships the DLL), locate mspdb140.dll via +# vswhere with a filesystem fallback, and prepend its directory to +# PATH for subsequent tasks via the `##vso[task.prependpath]` logging +# command. +# +# This script is intended to be invoked by a PowerShell@2 task with +# `filePath:`. It takes no arguments and writes diagnostics to stdout +# so install failures can be diagnosed from the task log. + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +$bootstrapper = "$env:TEMP\vs_BuildTools.exe" +Write-Host "Downloading VS 2022 Build Tools bootstrapper..." +Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vs_BuildTools.exe' ` + -OutFile $bootstrapper + +$vsArgs = @( + '--quiet', '--wait', '--norestart', '--nocache', + '--add', 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64' +) +Write-Host "Installing VS Build Tools (args: $($vsArgs -join ' '))..." +$start = Get-Date +$p = Start-Process -FilePath $bootstrapper -ArgumentList $vsArgs -Wait -PassThru +$elapsed = (Get-Date) - $start +Write-Host ("Installer exited with code {0} after {1:N0}s" -f ` + $p.ExitCode, $elapsed.TotalSeconds) + +Write-Host "" +Write-Host "===== Installer logs in `$env:TEMP =====" +$logs = Get-ChildItem $env:TEMP -Filter 'dd_*.log' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending +if ($logs) { + foreach ($log in $logs | Select-Object -First 5) { + Write-Host "----- $($log.FullName) (last 50 lines) -----" + Get-Content $log.FullName -Tail 50 -ErrorAction SilentlyContinue + } +} else { + Write-Host "(no dd_*.log files found in `$env:TEMP)" +} + +Write-Host "" +Write-Host "===== vswhere -all -prerelease (every install) =====" +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +if (-not (Test-Path $vswhere)) { + Write-Host "vswhere not found at $vswhere" +} else { + & $vswhere -all -prerelease -format json | + Out-String | Write-Host +} + +Write-Host "" +Write-Host "===== Filesystem search for mspdb*.dll =====" +$roots = @( + "${env:ProgramFiles(x86)}\Microsoft Visual Studio", + "${env:ProgramFiles}\Microsoft Visual Studio" +) | Where-Object { Test-Path $_ } +$hits = foreach ($r in $roots) { + Get-ChildItem -Path $r -Filter 'mspdb*.dll' -Recurse -File ` + -ErrorAction SilentlyContinue +} +if ($hits) { + $hits | ForEach-Object { Write-Host $_.FullName } +} else { + Write-Host "(no mspdb*.dll under any VS install root)" +} + +# 3010 = reboot required, treated as success. +if ($p.ExitCode -notin 0,3010) { + throw "VS Build Tools installer exited with code $($p.ExitCode)" +} + +Write-Host "" +Write-Host "===== Locate mspdb140.dll via vswhere -find =====" +$mspdb = & $vswhere -latest ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -find 'VC\Tools\MSVC\**\bin\Hostx64\x64\mspdb140.dll' | + Select-Object -First 1 +if (-not $mspdb) { + # Fall back to filesystem hits we already have. + $mspdb = $hits | + Where-Object { $_.Name -ieq 'mspdb140.dll' } | + Select-Object -First 1 -ExpandProperty FullName +} +if (-not $mspdb) { + throw "mspdb140.dll not found after install (see logs above)" +} +$dir = Split-Path -Parent $mspdb +Write-Host "Found mspdb140.dll at $mspdb" +Write-Host "##vso[task.prependpath]$dir" From 8f14fdd4f9bd431e0a53635be7b3aaabd18b6668 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Fri, 8 May 2026 09:06:01 +0200 Subject: [PATCH 23/23] fixup! azure-pipelines: customise the Windows installer via patches Appease the `check-whitespace` workflow. Signed-off-by: Johannes Schindelin --- .azure-pipelines/patches/.gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .azure-pipelines/patches/.gitattributes diff --git a/.azure-pipelines/patches/.gitattributes b/.azure-pipelines/patches/.gitattributes new file mode 100644 index 00000000000000..ef9170ec0077a5 --- /dev/null +++ b/.azure-pipelines/patches/.gitattributes @@ -0,0 +1 @@ +*.patch whitespace=-trailing-space,-blank-at-eof