diff --git a/.azure-pipelines/esrp/sign.yml b/.azure-pipelines/esrp/sign.yml
new file mode 100644
index 0000000000..31c9822eba
--- /dev/null
+++ b/.azure-pipelines/esrp/sign.yml
@@ -0,0 +1,176 @@
+# 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
+ # Comma-separated list of MIME types (as reported by 'file --mime-type'). When
+ # set, the staged files are filtered down to just those whose type matches one
+ # of the listed types before signing, so a mixed payload can be passed straight
+ # through and only the matching files are signed (e.g. pass
+ # 'application/x-mach-binary' to sign only Mach-O executables and dylibs). Only
+ # applies when useArchive is true.
+ - name: archiveMimeFilter
+ type: string
+ default: ''
+ # Where to move the CodeSignSummary-*.md receipt that ESRP writes alongside
+ # the signed files. When empty (default) the receipt is deleted instead, so it
+ # is never packaged or published with the signed output.
+ - name: codeSignSummaryPath
+ type: string
+ default: ''
+ # 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'
+ - ${{ if ne(parameters.archiveMimeFilter, '') }}:
+ # Filter the staged files to the requested MIME type(s); ESRP would
+ # otherwise try to sign every file in a mixed payload.
+ - task: Bash@3
+ displayName: 'Filter files by MIME type for ${{ parameters.displayName }}'
+ inputs:
+ targetType: inline
+ script: |
+ set -euo pipefail
+ dir="$(Agent.TempDirectory)/esrp-staging/contents"
+ # Comma-separated list of allowed 'file --mime-type' values; strip spaces.
+ filter="$(printf '%s' "${{ parameters.archiveMimeFilter }}" | tr -d '[:space:]')"
+ find "$dir" -type f -print0 \
+ | while IFS= read -r -d '' f; do
+ mt="$(file --mime-type -b "$f" 2>/dev/null || true)"
+ # For fat/universal binaries 'file' prints a line per
+ # architecture; the first line is the overall type.
+ mt="${mt%%$'\n'*}"
+ case ",$filter," in
+ *",$mt,"*) : ;; # keep: type is in the filter list
+ *) rm -f "$f" ;; # drop: not a requested type
+ esac
+ done
+ # Drop any directories left empty by the filtering.
+ find "$dir" -type d -empty -delete 2>/dev/null || true
+ - 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 }}'
+ # Only the signed files are present in the returned archive (the MIME
+ # filter can exclude others), so cleaning the destination would delete
+ # every unsigned file that belongs in the folder. Overwrite the
+ # originals in place instead of cleaning first.
+ cleanDestinationFolder: false
+ overwriteExistingFiles: true
+ # ESRP writes a CodeSignSummary-*.md receipt into the staging dir; move it
+ # aside first when a destination is given, otherwise the cleanup below
+ # deletes it along with the rest of the staging dir.
+ - ${{ if ne(parameters.codeSignSummaryPath, '') }}:
+ - task: CopyFiles@2
+ displayName: 'Move code signing summary for ${{ parameters.displayName }}'
+ inputs:
+ SourceFolder: '$(Agent.TempDirectory)/esrp-staging'
+ Contents: '**/CodeSignSummary-*.md'
+ TargetFolder: '${{ parameters.codeSignSummaryPath }}'
+ - 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 }}
+ # ESRP writes a CodeSignSummary-*.md receipt next to the signed files. Move
+ # it to the given destination when set, then always remove it from the
+ # signed folder so it is not packaged or published with the output.
+ - ${{ if ne(parameters.codeSignSummaryPath, '') }}:
+ - task: CopyFiles@2
+ displayName: 'Move code signing summary for ${{ parameters.displayName }}'
+ inputs:
+ SourceFolder: '${{ parameters.folderPath }}'
+ Contents: '**/CodeSignSummary-*.md'
+ TargetFolder: '${{ parameters.codeSignSummaryPath }}'
+ - task: DeleteFiles@1
+ displayName: 'Remove code signing summary for ${{ parameters.displayName }}'
+ inputs:
+ SourceFolder: '${{ parameters.folderPath }}'
+ Contents: '**/CodeSignSummary-*.md'
diff --git a/.azure-pipelines/nuget.config b/.azure-pipelines/nuget.config
new file mode 100644
index 0000000000..0cdfa50d8e
--- /dev/null
+++ b/.azure-pipelines/nuget.config
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml
index a957609d89..8ba259ffa7 100644
--- a/.azure-pipelines/release.yml
+++ b/.azure-pipelines/release.yml
@@ -134,52 +134,53 @@ extends:
artifactName: '${{ dim.runtime }}'
steps:
- checkout: self
+ - task: CopyFiles@2
+ displayName: 'Use Central Feed Services (CFS)'
+ inputs:
+ SourceFolder: '$(Build.SourcesDirectory)\.azure-pipelines'
+ Contents: 'nuget.config'
+ TargetFolder: '$(Build.SourcesDirectory)'
+ Overwrite: true
+ - task: NuGetAuthenticate@1
+ displayName: 'Authenticate to NuGet feeds'
- task: PowerShell@2
- displayName: 'Read version file'
+ displayName: 'Install .NET 10 SDK'
inputs:
targetType: inline
script: |
- $version = (Get-Content .\VERSION) -replace '\.\d+$', ''
- Write-Host "##vso[task.setvariable variable=version;isReadOnly=true]$version"
- - task: UseDotNet@2
- displayName: 'Use .NET 10 SDK'
+ # UseDotNet@2 mis-detects Windows-on-Arm agents as win-x86
+ # and installs the 32-bit SDK, which breaks the win-arm64
+ # AOT publish. Call dotnet-install directly, keyed on the
+ # agent architecture, so each leg gets a matching host SDK.
+ $ProgressPreference = 'SilentlyContinue'
+ $installDir = Join-Path $env:AGENT_TEMPDIRECTORY 'dotnet'
+ $installer = Join-Path $env:AGENT_TEMPDIRECTORY 'dotnet-install.ps1'
+ Invoke-WebRequest -Uri 'https://dot.net/v1/dotnet-install.ps1' -OutFile $installer
+ & $installer -Channel 10.0 -Architecture ${{ dim.poolArch }} -InstallDir $installDir
+ Write-Host "##vso[task.prependpath]$installDir"
+ # Native AOT links the published binary with the MSVC linker,
+ # which our images do not ship by default. Install the VC++
+ # build tools for the agent's architecture first.
+ - task: PowerShell@2
+ displayName: 'Install C++ build tools for AOT'
inputs:
- packageType: sdk
- version: '10.x'
+ filePath: '.azure-pipelines/scripts/windows/setup-aot-build-tools.ps1'
+ arguments: -Architecture ${{ dim.poolArch }}
- task: PowerShell@2
- displayName: 'Build payload'
+ displayName: 'Publish payload'
inputs:
+ pwsh: true
targetType: filePath
- filePath: '.\src\windows\Installer.Windows\layout.ps1'
- arguments: |
- -Configuration Release `
- -Output $(Build.ArtifactStagingDirectory)\payload `
- -SymbolOutput $(Build.ArtifactStagingDirectory)\symbols_raw `
- -RuntimeIdentifier ${{ dim.runtime }}
- - task: ArchiveFiles@2
- displayName: 'Archive symbols'
- inputs:
- rootFolderOrFile: '$(Build.ArtifactStagingDirectory)\symbols_raw'
- includeRootFolder: false
- archiveType: zip
- archiveFile: '$(Build.ArtifactStagingDirectory)\symbols\gcm-${{ dim.runtime }}-$(version)-symbols.zip'
+ filePath: '.\build\windows\publish.ps1'
+ arguments: -Configuration release -Runtime ${{ dim.runtime }}
+ # Sign the payload binaries before they are packaged into the
+ # installers.
- ${{ if eq(parameters.esrp, true) }}:
- - task: EsrpCodeSigning@5
- displayName: 'Sign payload'
- inputs:
- connectedServiceName: '$(esrpAppConnectionName)'
- useMSIAuthentication: true
- appRegistrationClientId: '$(esrpClientId)'
- appRegistrationTenantId: '$(esrpTenantId)'
- authAkvName: '$(esrpKeyVaultName)'
- authSignCertName: '$(esrpSignReqCertName)'
- serviceEndpointUrl: '$(esrpEndpointUrl)'
- folderPath: '$(Build.ArtifactStagingDirectory)\payload'
- pattern: |
- **/*.exe
- **/*.dll
- useMinimatch: true
- signConfigType: inlineSignParams
+ - template: .azure-pipelines/esrp/sign.yml@self
+ parameters:
+ displayName: 'Sign payload'
+ folderPath: '$(Build.SourcesDirectory)\out\publish\git-credential-manager\release_${{ dim.runtime }}'
+ pattern: '**/*.{exe,dll}'
inlineOperation: |
[
{
@@ -203,40 +204,34 @@ extends:
"Parameters": {}
}
]
- - task: PowerShell@2
- displayName: 'Clean up code signing artifacts'
- inputs:
- targetType: inline
- script: |
- Remove-Item "$(Build.ArtifactStagingDirectory)\payload\CodeSignSummary-*.md"
+ # Download the Inno Setup compiler (ISCC.exe) used by pack.ps1 to
+ # build the installers, and expose its path to the pack step. The
+ # script downloads the Tools.InnoSetup version pinned in
+ # Directory.Packages.props.
- task: PowerShell@2
- displayName: 'Build installers'
+ displayName: 'Download Inno Setup'
inputs:
+ pwsh: true
targetType: inline
script: |
- dotnet build '.\src\windows\Installer.Windows\Installer.Windows.csproj' `
- --configuration Release `
- --no-dependencies `
- -p:NoLayout=true `
- -p:PayloadPath="$(Build.ArtifactStagingDirectory)\payload" `
- -p:OutputPath="$(Build.ArtifactStagingDirectory)\installers" `
- -p:RuntimeIdentifier="${{ dim.runtime }}"
+ $inno = & "$(Build.SourcesDirectory)\.azure-pipelines\scripts\windows\download-innosetup.ps1" -OutputPath "$(Agent.TempDirectory)\innosetup"
+ Write-Host "Resolved Inno Setup $($inno.Version): $($inno.Path)"
+ Write-Host "##vso[task.setvariable variable=innoSetupCompiler]$($inno.Path)"
+ # Build the installer packages (.exe) from the signed payload
+ - task: PowerShell@2
+ displayName: 'Build installers'
+ inputs:
+ pwsh: true
+ targetType: filePath
+ filePath: '.\build\windows\pack.ps1'
+ arguments: -Configuration release -Runtime ${{ dim.runtime }} -InnoSetup "$(innoSetupCompiler)"
+ # Sign the installer executables
- ${{ if eq(parameters.esrp, true) }}:
- - task: EsrpCodeSigning@5
- condition: and(succeeded(), eq('${{ parameters.esrp }}', true))
- displayName: 'Sign installers'
- inputs:
- connectedServiceName: '$(esrpAppConnectionName)'
- useMSIAuthentication: true
- appRegistrationClientId: '$(esrpClientId)'
- appRegistrationTenantId: '$(esrpTenantId)'
- authAkvName: '$(esrpKeyVaultName)'
- authSignCertName: '$(esrpSignReqCertName)'
- serviceEndpointUrl: '$(esrpEndpointUrl)'
- folderPath: '$(Build.ArtifactStagingDirectory)\installers'
+ - template: .azure-pipelines/esrp/sign.yml@self
+ parameters:
+ displayName: 'Sign installers'
+ folderPath: '$(Build.SourcesDirectory)\out\package\release'
pattern: '**/*.exe'
- useMinimatch: true
- signConfigType: inlineSignParams
inlineOperation: |
[
{
@@ -260,23 +255,23 @@ extends:
"Parameters": {}
}
]
- - task: ArchiveFiles@2
+ # Archive the signed payload (.zip) alongside the installers
+ - task: PowerShell@2
displayName: 'Archive payload'
inputs:
- rootFolderOrFile: '$(Build.ArtifactStagingDirectory)\payload'
- includeRootFolder: false
- archiveType: zip
- archiveFile: '$(Build.ArtifactStagingDirectory)\installers\gcm-${{ dim.runtime }}-$(version).zip'
+ pwsh: true
+ targetType: filePath
+ filePath: '.\build\windows\archive.ps1'
+ arguments: -Configuration release -Runtime ${{ dim.runtime }}
- task: PowerShell@2
displayName: 'Collect artifacts for publishing'
inputs:
+ pwsh: true
targetType: inline
script: |
New-Item -Path "$(Build.ArtifactStagingDirectory)\_final" -ItemType Directory -Force
- Copy-Item "$(Build.ArtifactStagingDirectory)\installers\*.exe" -Destination "$(Build.ArtifactStagingDirectory)\_final"
- Copy-Item "$(Build.ArtifactStagingDirectory)\installers\*.zip" -Destination "$(Build.ArtifactStagingDirectory)\_final"
- Copy-Item "$(Build.ArtifactStagingDirectory)\symbols\*.zip" -Destination "$(Build.ArtifactStagingDirectory)\_final"
- Copy-Item "$(Build.ArtifactStagingDirectory)\payload" -Destination "$(Build.ArtifactStagingDirectory)\_final" -Recurse
+ Copy-Item "$(Build.SourcesDirectory)\out\package\release\*.exe" -Destination "$(Build.ArtifactStagingDirectory)\_final"
+ Copy-Item "$(Build.SourcesDirectory)\out\package\release\*.zip" -Destination "$(Build.ArtifactStagingDirectory)\_final"
#
# macOS build jobs
@@ -295,120 +290,61 @@ extends:
artifactName: '${{ dim.runtime }}'
steps:
- checkout: self
- - task: Bash@3
- displayName: 'Read version file'
+ - task: CopyFiles@2
+ displayName: 'Use Central Feed Services (CFS)'
inputs:
- targetType: inline
- script: |
- echo "##vso[task.setvariable variable=version;isReadOnly=true]$(cat ./VERSION | sed -E 's/.[0-9]+$//')"
- - task: UseDotNet@2
- displayName: 'Use .NET 8 SDK (ESRP dependency)'
- inputs:
- packageType: sdk
- version: '8.x'
+ SourceFolder: '$(Build.SourcesDirectory)/.azure-pipelines'
+ Contents: 'nuget.config'
+ TargetFolder: '$(Build.SourcesDirectory)'
+ Overwrite: true
+ - task: NuGetAuthenticate@1
+ displayName: 'Authenticate to NuGet feeds'
- task: UseDotNet@2
displayName: 'Use .NET 10 SDK'
inputs:
packageType: sdk
version: '10.x'
- task: Bash@3
- displayName: 'Build payload'
+ displayName: 'Publish payload'
inputs:
targetType: filePath
- filePath: './src/osx/Installer.Mac/layout.sh'
+ filePath: './build/macos/publish.sh'
arguments: |
- --runtime="${{ dim.runtime }}" \
- --configuration="Release" \
- --output="$(Build.ArtifactStagingDirectory)/payload" \
- --symbol-output="$(Build.ArtifactStagingDirectory)/symbols_raw"
- - task: ArchiveFiles@2
- displayName: 'Archive symbols'
- inputs:
- rootFolderOrFile: '$(Build.ArtifactStagingDirectory)/symbols_raw'
- includeRootFolder: false
- archiveType: tar
- tarCompression: gz
- archiveFile: '$(Build.ArtifactStagingDirectory)/symbols/gcm-${{ dim.runtime }}-$(version)-symbols.tar.gz'
+ --configuration release \
+ --runtime ${{ dim.runtime }}
- ${{ if eq(parameters.esrp, true) }}:
- - task: AzureKeyVault@2
- displayName: 'Download developer certificate'
- inputs:
- azureSubscription: '$(esrpMIConnectionName)'
- keyVaultName: '$(esrpKeyVaultName)'
- secretsFilter: 'mac-developer-certificate,mac-developer-certificate-password,mac-developer-certificate-identity'
- - task: Bash@3
+ - task: AzureCLI@2
displayName: 'Import developer certificate'
inputs:
- targetType: inline
- script: |
- # Create and unlock a keychain for the developer certificate
- security create-keychain -p pwd $(Agent.TempDirectory)/buildagent.keychain
- security default-keychain -s $(Agent.TempDirectory)/buildagent.keychain
- security unlock-keychain -p pwd $(Agent.TempDirectory)/buildagent.keychain
-
- echo $(mac-developer-certificate) | base64 -D > $(Agent.TempDirectory)/cert.p12
- echo $(mac-developer-certificate-password) > $(Agent.TempDirectory)/cert.password
-
- # Import the developer certificate
- security import $(Agent.TempDirectory)/cert.p12 \
- -k $(Agent.TempDirectory)/buildagent.keychain \
- -P "$(mac-developer-certificate-password)" \
- -T /usr/bin/codesign
-
- # Clean up the cert file immediately after import
- rm $(Agent.TempDirectory)/cert.p12
-
- # Set ACLs to allow codesign to access the private key
- security set-key-partition-list \
- -S apple-tool:,apple:,codesign: \
- -s -k pwd \
- $(Agent.TempDirectory)/buildagent.keychain
+ azureSubscription: '$(esrpMIConnectionName)'
+ scriptType: bash
+ scriptLocation: inlineScript
+ inlineScript: |
+ set -euo pipefail
+ identity=$(./.azure-pipelines/scripts/macos/import-developer-certificate.sh \
+ --vault "$(esrpKeyVaultName)" \
+ --keychain "$(Agent.TempDirectory)/buildagent.keychain")
+ echo "##vso[task.setvariable variable=macDevCertIdentity;issecret=true]$identity"
+ # Developer-sign the payload (attaching entitlements and the
+ # hardened runtime) and then ESRP-sign the Mach-O binaries in
+ # place.
+ - ${{ if eq(parameters.esrp, true) }}:
- task: Bash@3
- displayName: 'Developer sign payload files'
- inputs:
- targetType: inline
- script: |
- mkdir -p $(Build.ArtifactStagingDirectory)/tosign/payload
-
- # Copy the files that need signing (Mach-o executables and dylibs)
- pushd $(Build.ArtifactStagingDirectory)/payload
- find . -type f -exec file --mime {} + \
- | sed -n '/mach/s/: .*//p' \
- | while IFS= read -r f; do
- rel="${f#./}"
- tgt="$(Build.ArtifactStagingDirectory)/tosign/payload/$rel"
- mkdir -p "$(dirname "$tgt")"
- cp -- "$f" "$tgt"
- done
- popd
-
- # Developer sign the files
- ./src/osx/Installer.Mac/codesign.sh \
- "$(Build.ArtifactStagingDirectory)/tosign/payload" \
- "$(mac-developer-certificate-identity)" \
- "$PWD/src/osx/Installer.Mac/entitlements.xml"
- # ESRP code signing for macOS requires the files be packaged in a zip file for submission
- - task: ArchiveFiles@2
- displayName: 'Archive files for signing'
+ displayName: 'Developer-sign payload'
inputs:
- rootFolderOrFile: '$(Build.ArtifactStagingDirectory)/tosign/payload'
- includeRootFolder: false
- archiveType: zip
- archiveFile: '$(Build.ArtifactStagingDirectory)/tosign/payload.zip'
- - task: EsrpCodeSigning@5
- displayName: 'Sign payload'
- inputs:
- connectedServiceName: '$(esrpAppConnectionName)'
- useMSIAuthentication: true
- appRegistrationClientId: '$(esrpClientId)'
- appRegistrationTenantId: '$(esrpTenantId)'
- authAkvName: '$(esrpKeyVaultName)'
- authSignCertName: '$(esrpSignReqCertName)'
- serviceEndpointUrl: '$(esrpEndpointUrl)'
- folderPath: '$(Build.ArtifactStagingDirectory)/tosign'
- pattern: 'payload.zip'
- useMinimatch: true
- signConfigType: inlineSignParams
+ targetType: filePath
+ filePath: './build/macos/codesign.sh'
+ arguments: |
+ developer \
+ --bindir "$(Build.SourcesDirectory)/out/publish/git-credential-manager/release_${{ dim.runtime }}" \
+ --identity "$(macDevCertIdentity)"
+ - template: .azure-pipelines/esrp/sign.yml@self
+ parameters:
+ displayName: 'Sign payload'
+ folderPath: '$(Build.SourcesDirectory)/out/publish/git-credential-manager/release_${{ dim.runtime }}'
+ pattern: '**/*'
+ useArchive: true # Required for macOS signing
+ archiveMimeFilter: 'application/x-mach-binary' # Sign only Mach-O binaries
inlineOperation: |
[
{
@@ -421,56 +357,23 @@ extends:
}
}
]
- # Extract signed files, overwriting the unsigned files, ready for packaging
- - task: Bash@3
- displayName: 'Extract signed payload files'
- inputs:
- targetType: inline
- script: |
- unzip -uo $(Build.ArtifactStagingDirectory)/tosign/payload.zip -d $(Build.ArtifactStagingDirectory)/payload
- - task: Bash@3
- displayName: 'Build component package'
- inputs:
- targetType: filePath
- filePath: './src/osx/Installer.Mac/pack.sh'
- arguments: |
- --version="$(version)" \
- --payload="$(Build.ArtifactStagingDirectory)/payload" \
- --output="$(Build.ArtifactStagingDirectory)/pkg/com.microsoft.gitcredentialmanager.component.pkg"
+ # Package the (signed) binaries into the installer (.pkg)
- task: Bash@3
- displayName: 'Build installer package'
+ displayName: 'Build installer'
inputs:
targetType: filePath
- filePath: './src/osx/Installer.Mac/dist.sh'
+ filePath: './build/macos/pack.sh'
arguments: |
- --version="$(version)" \
- --runtime="${{ dim.runtime }}" \
- --package-path="$(Build.ArtifactStagingDirectory)/pkg" \
- --output="$(Build.ArtifactStagingDirectory)/installers/gcm-${{ dim.runtime }}-$(version).pkg"
+ --configuration release \
+ --runtime ${{ dim.runtime }}
+ # ESRP-sign and notarize the installer package
- ${{ if eq(parameters.esrp, true) }}:
- # ESRP code signing for macOS requires the files be packaged in a zip file first
- - task: Bash@3
- displayName: 'Prepare installer package for signing'
- inputs:
- targetType: inline
- script: |
- mkdir -p $(Build.ArtifactStagingDirectory)/tosign
- cd $(Build.ArtifactStagingDirectory)/installers
- zip -rX $(Build.ArtifactStagingDirectory)/tosign/installers.zip *.pkg
- - task: EsrpCodeSigning@5
- displayName: 'Sign installer package'
- inputs:
- connectedServiceName: '$(esrpAppConnectionName)'
- useMSIAuthentication: true
- appRegistrationClientId: '$(esrpClientId)'
- appRegistrationTenantId: '$(esrpTenantId)'
- authAkvName: '$(esrpKeyVaultName)'
- authSignCertName: '$(esrpSignReqCertName)'
- serviceEndpointUrl: '$(esrpEndpointUrl)'
- folderPath: '$(Build.ArtifactStagingDirectory)/tosign'
- pattern: 'installers.zip'
- useMinimatch: true
- signConfigType: inlineSignParams
+ - template: .azure-pipelines/esrp/sign.yml@self
+ parameters:
+ displayName: 'Sign installer'
+ folderPath: '$(Build.SourcesDirectory)/out/package/release'
+ pattern: '*.pkg'
+ useArchive: true # Required for macOS signing
inlineOperation: |
[
{
@@ -483,37 +386,12 @@ extends:
}
}
]
- # Extract signed installer, overwriting the unsigned installer
- - task: Bash@3
- displayName: 'Extract signed installer package'
- inputs:
- targetType: inline
- script: |
- unzip -uo $(Build.ArtifactStagingDirectory)/tosign/installers.zip -d $(Build.ArtifactStagingDirectory)/installers
- - task: Bash@3
- displayName: 'Prepare installer package for notarization'
- inputs:
- targetType: inline
- script: |
- mkdir -p $(Build.ArtifactStagingDirectory)/tosign
- cd $(Build.ArtifactStagingDirectory)/installers
- # Remove previous installers.zip to avoid any confusion
- rm -f $(Build.ArtifactStagingDirectory)/tosign/installers.zip
- zip -rX $(Build.ArtifactStagingDirectory)/tosign/installers.zip *.pkg
- - task: EsrpCodeSigning@5
- displayName: 'Notarize installer package'
- inputs:
- connectedServiceName: '$(esrpAppConnectionName)'
- useMSIAuthentication: true
- appRegistrationClientId: '$(esrpClientId)'
- appRegistrationTenantId: '$(esrpTenantId)'
- authAkvName: '$(esrpKeyVaultName)'
- authSignCertName: '$(esrpSignReqCertName)'
- serviceEndpointUrl: '$(esrpEndpointUrl)'
- folderPath: '$(Build.ArtifactStagingDirectory)/tosign'
- pattern: 'installers.zip'
- useMinimatch: true
- signConfigType: inlineSignParams
+ - template: .azure-pipelines/esrp/sign.yml@self
+ parameters:
+ displayName: 'Notarize installer'
+ folderPath: '$(Build.SourcesDirectory)/out/package/release'
+ pattern: '*.pkg'
+ useArchive: false # Notarization takes the .pkg container directly without archiving
inlineOperation: |
[
{
@@ -526,31 +404,23 @@ extends:
}
}
]
- # Extract signed and notarized installer pkg files, overwriting the unsigned files, ready for upload
- - task: Bash@3
- displayName: 'Extract signed and notarized installer package'
- inputs:
- targetType: inline
- script: |
- unzip -uo $(Build.ArtifactStagingDirectory)/tosign/installers.zip -d $(Build.ArtifactStagingDirectory)/installers
- - task: ArchiveFiles@2
+ # Create the (signed) binaries and symbols tarballs
+ - task: Bash@3
displayName: 'Archive payload'
inputs:
- rootFolderOrFile: '$(Build.ArtifactStagingDirectory)/payload'
- includeRootFolder: false
- archiveType: tar
- tarCompression: gz
- archiveFile: '$(Build.ArtifactStagingDirectory)/installers/gcm-${{ dim.runtime }}-$(version).tar.gz'
+ targetType: filePath
+ filePath: './build/macos/archive.sh'
+ arguments: |
+ --configuration release \
+ --runtime ${{ dim.runtime }}
- task: Bash@3
displayName: 'Collect artifacts for publishing'
inputs:
targetType: inline
script: |
mkdir -p $(Build.ArtifactStagingDirectory)/_final
- cp $(Build.ArtifactStagingDirectory)/installers/*.pkg $(Build.ArtifactStagingDirectory)/_final
- cp $(Build.ArtifactStagingDirectory)/installers/*.tar.gz $(Build.ArtifactStagingDirectory)/_final
- cp $(Build.ArtifactStagingDirectory)/symbols/*.tar.gz $(Build.ArtifactStagingDirectory)/_final
- cp -r $(Build.ArtifactStagingDirectory)/payload $(Build.ArtifactStagingDirectory)/_final
+ cp $(Build.SourcesDirectory)/out/package/release/*.pkg $(Build.ArtifactStagingDirectory)/_final
+ cp $(Build.SourcesDirectory)/out/package/release/*.tar.gz $(Build.ArtifactStagingDirectory)/_final
#
# Linux build jobs
@@ -570,72 +440,45 @@ extends:
artifactName: '${{ dim.runtime }}'
steps:
- checkout: self
- - task: Bash@3
- displayName: 'Read version file'
- inputs:
- targetType: inline
- script: |
- echo "##vso[task.setvariable variable=version;isReadOnly=true]$(cat ./VERSION | sed -E 's/.[0-9]+$//')"
- - task: UseDotNet@2
- displayName: 'Use .NET 8 SDK (ESRP dependency)'
+ - task: CopyFiles@2
+ displayName: 'Use Central Feed Services (CFS)'
inputs:
- packageType: sdk
- version: '8.x'
+ SourceFolder: '$(Build.SourcesDirectory)/.azure-pipelines'
+ Contents: 'nuget.config'
+ TargetFolder: '$(Build.SourcesDirectory)'
+ Overwrite: true
+ - task: NuGetAuthenticate@1
+ displayName: 'Authenticate to NuGet feeds'
- task: UseDotNet@2
displayName: 'Use .NET 10 SDK'
inputs:
packageType: sdk
version: '10.x'
+ # Publish the application payload
- task: Bash@3
- displayName: 'Build payload'
+ displayName: 'Publish payload'
inputs:
targetType: filePath
- filePath: './src/linux/Packaging.Linux/layout.sh'
+ filePath: './build/linux/publish.sh'
arguments: |
- --runtime="${{ dim.runtime }}" \
- --configuration="Release" \
- --output="$(Build.ArtifactStagingDirectory)/payload" \
- --symbol-output="$(Build.ArtifactStagingDirectory)/symbols_raw"
+ --configuration release \
+ --runtime ${{ dim.runtime }}
+ # Build the package (.deb) from the payload
- task: Bash@3
- displayName: 'Build packages'
+ displayName: 'Build package'
inputs:
targetType: filePath
- filePath: './src/linux/Packaging.Linux/pack.sh'
+ filePath: './build/linux/pack.sh'
arguments: |
- --version="$(version)" \
- --runtime="${{ dim.runtime }}" \
- --payload="$(Build.ArtifactStagingDirectory)/payload" \
- --symbols="$(Build.ArtifactStagingDirectory)/symbols_raw" \
- --output="$(Build.ArtifactStagingDirectory)/pkg"
- - task: Bash@3
- displayName: 'Move packages'
- inputs:
- targetType: inline
- script: |
- # Move symbols
- mkdir -p $(Build.ArtifactStagingDirectory)/symbols
- mv $(Build.ArtifactStagingDirectory)/pkg/tar/gcm-*-symbols.tar.gz $(Build.ArtifactStagingDirectory)/symbols
-
- # Move binary packages
- mkdir -p $(Build.ArtifactStagingDirectory)/installers
- mv $(Build.ArtifactStagingDirectory)/pkg/tar/*.tar.gz $(Build.ArtifactStagingDirectory)/installers
- mv $(Build.ArtifactStagingDirectory)/pkg/deb/*.deb $(Build.ArtifactStagingDirectory)/installers
+ --configuration release \
+ --runtime ${{ dim.runtime }}
+ # ESRP-sign the Debian package
- ${{ if eq(parameters.esrp, true) }}:
- - task: EsrpCodeSigning@5
- displayName: 'Sign Debian package'
- inputs:
- connectedServiceName: '$(esrpAppConnectionName)'
- useMSIAuthentication: true
- appRegistrationClientId: '$(esrpClientId)'
- appRegistrationTenantId: '$(esrpTenantId)'
- authAkvName: '$(esrpKeyVaultName)'
- authSignCertName: '$(esrpSignReqCertName)'
- serviceEndpointUrl: '$(esrpEndpointUrl)'
- folderPath: '$(Build.ArtifactStagingDirectory)/installers'
- pattern: |
- **/*.deb
- useMinimatch: true
- signConfigType: inlineSignParams
+ - template: .azure-pipelines/esrp/sign.yml@self
+ parameters:
+ displayName: 'Sign Debian package'
+ folderPath: '$(Build.SourcesDirectory)/out/package/release'
+ pattern: '**/*.deb'
inlineOperation: |
[
{
@@ -646,75 +489,70 @@ extends:
"Parameters": {}
}
]
+ # Create the binaries and symbols tarballs
+ - task: Bash@3
+ displayName: 'Archive payload'
+ inputs:
+ targetType: filePath
+ filePath: './build/linux/archive.sh'
+ arguments: |
+ --configuration release \
+ --runtime ${{ dim.runtime }}
- task: Bash@3
displayName: 'Collect artifacts for publishing'
inputs:
targetType: inline
script: |
mkdir -p $(Build.ArtifactStagingDirectory)/_final
- cp $(Build.ArtifactStagingDirectory)/installers/*.deb $(Build.ArtifactStagingDirectory)/_final
- cp $(Build.ArtifactStagingDirectory)/installers/*.tar.gz $(Build.ArtifactStagingDirectory)/_final
- cp $(Build.ArtifactStagingDirectory)/symbols/*.tar.gz $(Build.ArtifactStagingDirectory)/_final
- cp -r $(Build.ArtifactStagingDirectory)/payload $(Build.ArtifactStagingDirectory)/_final
+ cp $(Build.SourcesDirectory)/out/package/release/*.deb $(Build.ArtifactStagingDirectory)/_final
+ cp $(Build.SourcesDirectory)/out/package/release/*.tar.gz $(Build.ArtifactStagingDirectory)/_final
#
- # .NET Tool build job
+ # .NET tool build job
#
- job: dotnet_tool
displayName: '.NET Tool NuGet Package'
+ # The tool is portable IL and doesn't require platform-specific
+ # builds. Linux is the fastest hosted agent to provision so use it!
pool:
name: GitClientPME-1ESHostedPool-intel-pc
- image: win-x86_64-ado1es
- os: windows
+ image: ubuntu-x86_64-ado1es
+ os: linux
+ hostArchitecture: amd64
templateContext:
outputs:
- output: pipelineArtifact
- targetPath: '$(Build.ArtifactStagingDirectory)/packages'
+ targetPath: '$(Build.ArtifactStagingDirectory)/_final'
artifactName: 'dotnet-tool'
steps:
- checkout: self
- - task: PowerShell@2
- displayName: 'Read version file'
+ - task: CopyFiles@2
+ displayName: 'Use Central Feed Services (CFS)'
inputs:
- targetType: inline
- script: |
- $version = (Get-Content .\VERSION) -replace '\.\d+$', ''
- Write-Host "##vso[task.setvariable variable=version;isReadOnly=true]$version"
+ SourceFolder: '$(Build.SourcesDirectory)/.azure-pipelines'
+ Contents: 'nuget.config'
+ TargetFolder: '$(Build.SourcesDirectory)'
+ Overwrite: true
+ - task: NuGetAuthenticate@1
+ displayName: 'Authenticate to NuGet feeds'
- task: UseDotNet@2
displayName: 'Use .NET 10 SDK'
inputs:
packageType: sdk
version: '10.x'
- - task: NuGetToolInstaller@1
- displayName: 'Install NuGet CLI'
- inputs:
- versionSpec: '>= 6.0'
- - task: PowerShell@2
- displayName: 'Build payload'
+ - task: Bash@3
+ displayName: 'Publish payload'
inputs:
targetType: filePath
- filePath: './src/shared/DotnetTool/layout.ps1'
- arguments: |
- -Configuration Release `
- -Output "$(Build.ArtifactStagingDirectory)/nupkg"
+ filePath: './build/dntool/publish.sh'
+ arguments: --configuration release
+ # ESRP-sign (Authenticode) the managed assemblies before packing
- ${{ if eq(parameters.esrp, true) }}:
- - task: EsrpCodeSigning@5
- condition: and(succeeded(), eq('${{ parameters.esrp }}', true))
- displayName: 'Sign payload'
- inputs:
- connectedServiceName: '$(esrpAppConnectionName)'
- useMSIAuthentication: true
- appRegistrationClientId: '$(esrpClientId)'
- appRegistrationTenantId: '$(esrpTenantId)'
- authAkvName: '$(esrpKeyVaultName)'
- authSignCertName: '$(esrpSignReqCertName)'
- serviceEndpointUrl: '$(esrpEndpointUrl)'
- folderPath: '$(Build.ArtifactStagingDirectory)/nupkg'
- pattern: |
- **/*.exe
- **/*.dll
- useMinimatch: true
- signConfigType: inlineSignParams
+ - template: .azure-pipelines/esrp/sign.yml@self
+ parameters:
+ displayName: 'Sign payload'
+ folderPath: '$(Build.SourcesDirectory)/out/publish/dntool/release'
+ pattern: '**/*.dll'
inlineOperation: |
[
{
@@ -738,44 +576,20 @@ extends:
"Parameters": {}
}
]
- - task: PowerShell@2
+ # Pack the signed payload into the .nupkg
+ - task: Bash@3
displayName: 'Create NuGet packages'
inputs:
targetType: filePath
- filePath: './src/shared/DotnetTool/pack.ps1'
- arguments: |
- -Configuration Release `
- -Version "$(version)" `
- -PackageRoot "$(Build.ArtifactStagingDirectory)/nupkg" `
- -Output "$(Build.ArtifactStagingDirectory)/packages"
- - ${{ if eq(parameters.esrp, true) }}:
- - task: EsrpCodeSigning@5
- condition: and(succeeded(), eq('${{ parameters.esrp }}', true))
- displayName: 'Sign NuGet packages'
- inputs:
- connectedServiceName: '$(esrpAppConnectionName)'
- useMSIAuthentication: true
- appRegistrationClientId: '$(esrpClientId)'
- appRegistrationTenantId: '$(esrpTenantId)'
- authAkvName: '$(esrpKeyVaultName)'
- authSignCertName: '$(esrpSignReqCertName)'
- serviceEndpointUrl: '$(esrpEndpointUrl)'
- folderPath: '$(Build.ArtifactStagingDirectory)/packages'
- pattern: |
- **/*.nupkg
- **/*.snupkg
- useMinimatch: true
- signConfigType: inlineSignParams
- inlineOperation: |
- [
- {
- "KeyCode": "CP-401405",
- "OperationCode": "NuGetSign",
- "ToolName": "sign",
- "ToolVersion": "1.0",
- "Parameters": {}
- }
- ]
+ filePath: './build/dntool/pack.sh'
+ arguments: --configuration release
+ - task: Bash@3
+ displayName: 'Collect artifacts for publishing'
+ inputs:
+ targetType: inline
+ script: |
+ mkdir -p $(Build.ArtifactStagingDirectory)/_final
+ cp $(Build.SourcesDirectory)/out/package/release/*.nupkg $(Build.ArtifactStagingDirectory)/_final
- stage: release
displayName: 'Release'
@@ -865,7 +679,6 @@ extends:
$(Pipeline.Workspace)/assets/linux-arm64/*.deb
$(Pipeline.Workspace)/assets/linux-arm64/*.tar.gz
$(Pipeline.Workspace)/assets/dotnet-tool/*.nupkg
- $(Pipeline.Workspace)/assets/dotnet-tool/*.snupkg
- job: nuget
displayName: 'Publish NuGet package'
@@ -890,7 +703,7 @@ extends:
- output: nuget
condition: and(succeeded(), eq('${{ parameters.nuget }}', true))
displayName: 'Publish .NET Tool NuGet package'
- packagesToPush: '$(Pipeline.Workspace)/assets/dotnet-tool/*.nupkg;$(Pipeline.Workspace)/assets/dotnet-tool/*.snupkg'
+ packagesToPush: '$(Pipeline.Workspace)/assets/dotnet-tool/*.nupkg'
packageParentPath: $(Pipeline.Workspace)/assets/dotnet-tool
nuGetFeedType: external
publishPackageMetadata: true
diff --git a/.azure-pipelines/scripts/macos/import-developer-certificate.sh b/.azure-pipelines/scripts/macos/import-developer-certificate.sh
new file mode 100755
index 0000000000..64d8bcb0f4
--- /dev/null
+++ b/.azure-pipelines/scripts/macos/import-developer-certificate.sh
@@ -0,0 +1,147 @@
+#!/bin/bash
+#
+# Downloads the macOS developer signing certificate (and its signing identity)
+# from Azure Key Vault and imports the certificate into a keychain so that
+# codesign can use it.
+#
+# This uses the Azure CLI ('az') to read the secrets, so it must run with an
+# authenticated CLI - e.g. inside an Azure Pipelines 'AzureCLI@2' task, which
+# provides a CLI authenticated against the task's service connection.
+#
+# The resolved developer signing identity is written to stdout; all progress
+# output goes to stderr, so a caller can capture just the identity with:
+#
+# identity="$(import-developer-certificate.sh --vault myvault)"
+#
+set -euo pipefail
+
+die () {
+ echo "fatal: $*" >&2
+ exit 1
+}
+
+make_absolute () {
+ case "$1" in
+ /*) echo "$1" ;;
+ *) echo "$PWD/$1" ;;
+ esac
+}
+
+print_usage () {
+ cat < [options]
+
+Download the macOS developer signing certificate from Azure Key Vault and import
+it into a keychain for codesign. Requires an authenticated Azure CLI ('az'). The
+developer signing identity is written to stdout.
+
+Options:
+ --vault Azure Key Vault to read the secrets from. (required)
+ --certificate-secret Key Vault secret holding the base64-encoded .p12
+ certificate. (default: mac-developer-certificate)
+ --password-secret Key Vault secret holding the .p12 password.
+ (default: mac-developer-certificate-password)
+ --identity-secret Key Vault secret holding the signing identity.
+ (default: mac-developer-certificate-identity)
+ --keychain Keychain to create and import into.
+ (default: \$TMPDIR/gcm-build.keychain)
+ -h, --help Show this help text and exit.
+
+Examples:
+ $(basename "$0") --vault my-key-vault
+EOF
+}
+
+# Defaults
+VAULT=""
+CERT_SECRET="mac-developer-certificate"
+PASSWORD_SECRET="mac-developer-certificate-password"
+IDENTITY_SECRET="mac-developer-certificate-identity"
+KEYCHAIN=""
+
+# Parse arguments.
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ -h|--help) print_usage; exit 0 ;;
+ --vault) VAULT="${2:?--vault requires a value}"; shift 2 ;;
+ --certificate-secret) CERT_SECRET="${2:?--certificate-secret requires a value}"; shift 2 ;;
+ --password-secret) PASSWORD_SECRET="${2:?--password-secret requires a value}"; shift 2 ;;
+ --identity-secret) IDENTITY_SECRET="${2:?--identity-secret requires a value}"; shift 2 ;;
+ --keychain) KEYCHAIN="${2:?--keychain requires a value}"; shift 2 ;;
+ --) shift; break ;;
+ -*) die "unknown option '$1' (try '$(basename "$0") --help')" ;;
+ *) die "unexpected argument '$1' (try '$(basename "$0") --help')" ;;
+ esac
+done
+
+[ "$#" -eq 0 ] || die "unexpected argument '$1' (try '$(basename "$0") --help')"
+[ -n "$VAULT" ] || die "--vault was not specified"
+
+# Resolve the keychain path (default: an ephemeral keychain under the temp dir).
+if [ -n "$KEYCHAIN" ]; then
+ KEYCHAIN="$(make_absolute "$KEYCHAIN")"
+else
+ KEYCHAIN="${TMPDIR:-/tmp}/gcm-build.keychain"
+fi
+
+command -v az >/dev/null 2>&1 || die "the Azure CLI ('az') is required but was not found on PATH"
+command -v security >/dev/null 2>&1 || die "'security' is required (this script must run on macOS)"
+
+# Send everything except the final identity to stderr, so stdout carries only
+# the signing identity for the caller to capture.
+exec 3>&1 1>&2
+
+echo "Importing developer certificate from Key Vault '$VAULT'..."
+echo "certificate secret: $CERT_SECRET"
+echo "password secret: $PASSWORD_SECRET"
+echo "identity secret: $IDENTITY_SECRET"
+echo "keychain: $KEYCHAIN"
+
+# Read the certificate password and signing identity (plain string secrets).
+CERT_PASSWORD="$(az keyvault secret show --vault-name "$VAULT" --name "$PASSWORD_SECRET" --query value -o tsv)" \
+ || die "failed to read secret '$PASSWORD_SECRET' from Key Vault '$VAULT'"
+IDENTITY="$(az keyvault secret show --vault-name "$VAULT" --name "$IDENTITY_SECRET" --query value -o tsv)" \
+ || die "failed to read secret '$IDENTITY_SECRET' from Key Vault '$VAULT'"
+
+# Download and base64-decode the .p12 certificate to a temporary file, removed
+# again as soon as it has been imported (or if the script exits early). mktemp
+# creates the file, so the download needs --overwrite to write into it (newer
+# az CLI refuses to overwrite an existing --file otherwise).
+CERT_FILE="$(mktemp)"
+trap 'rm -f "$CERT_FILE"' EXIT
+az keyvault secret download --vault-name "$VAULT" --name "$CERT_SECRET" \
+ --encoding base64 --file "$CERT_FILE" --overwrite \
+ || die "failed to download secret '$CERT_SECRET' from Key Vault '$VAULT'"
+
+# Use a random, ephemeral password for the throwaway build keychain; it is only
+# ever used here (the keychain stays unlocked for codesign in later steps).
+KEYCHAIN_PASSWORD="$(uuidgen)"
+
+# Create, unlock and default the keychain so codesign can find the identity.
+echo "Creating keychain '$KEYCHAIN'..."
+rm -f "$KEYCHAIN"
+security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
+security default-keychain -s "$KEYCHAIN"
+security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
+
+# A new keychain keeps a default auto-lock (300s timeout and lock-on-sleep) that
+# 'unlock-keychain' does not clear, so it re-locks part-way through a long
+# signing run and makes codesign block on a GUI unlock prompt (which hangs
+# headless CI). Disable auto-lock so it stays unlocked for the whole build.
+security set-keychain-settings "$KEYCHAIN"
+
+# Import the certificate, authorising codesign to use the private key. The
+# format is stated explicitly because 'security import' otherwise infers it
+# from the file extension, and the mktemp file deliberately has none (without
+# this it fails with "SecKeychainItemImport: Unknown format in import").
+echo "Importing certificate..."
+security import "$CERT_FILE" -f pkcs12 -k "$KEYCHAIN" -P "$CERT_PASSWORD" -T /usr/bin/codesign
+
+# Allow codesign to use the private key without an interactive prompt.
+security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
+
+echo "Developer certificate imported."
+echo "signing identity: $IDENTITY"
+
+# Emit the signing identity on the real stdout for the caller.
+printf '%s\n' "$IDENTITY" >&3
diff --git a/.azure-pipelines/scripts/windows/download-innosetup.ps1 b/.azure-pipelines/scripts/windows/download-innosetup.ps1
new file mode 100644
index 0000000000..430cb15b45
--- /dev/null
+++ b/.azure-pipelines/scripts/windows/download-innosetup.ps1
@@ -0,0 +1,101 @@
+<#
+.SYNOPSIS
+ Downloads the Inno Setup compiler (ISCC.exe) used to build the Windows
+ installers, without requiring a project file.
+
+.DESCRIPTION
+ Uses 'dotnet package download' (.NET SDK 10+) to fetch the Tools.InnoSetup
+ NuGet package into a local folder, then returns an object describing the
+ download. By default the version pinned for the build in
+ Directory.Packages.props is used, so the installers are compiled with the
+ same Inno Setup as a regular 'dotnet build'.
+
+ The returned object lets the caller either pass the compiler path to
+ pack.ps1 (-InnoSetup) or add the tools directory to PATH (e.g. via an Azure
+ Pipelines '##vso[task.prependpath]' command); this script itself stays
+ agnostic of the build/CI system.
+
+.PARAMETER Version
+ The Tools.InnoSetup package version to download. Defaults to the version
+ pinned in Directory.Packages.props.
+
+.PARAMETER OutputPath
+ Directory to download the package into. Defaults to out/tools/innosetup under
+ the repository root.
+
+.OUTPUTS
+ A [pscustomobject] with these properties:
+ Version - the resolved Tools.InnoSetup package version.
+ ToolsDir - the directory containing the Inno Setup binaries (ISCC.exe etc.).
+ Path - the full path to ISCC.exe.
+#>
+[CmdletBinding()]
+param (
+ [string] $Version,
+ [string] $OutputPath
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$InformationPreference = 'Continue'
+
+# Repository root, three levels up from this script
+# (.azure-pipelines/scripts/windows).
+function Get-RepoRoot {
+ return (Get-Item $PSScriptRoot).Parent.Parent.Parent.FullName
+}
+
+function Get-AbsolutePath {
+ param([Parameter(Mandatory)] [string] $Path)
+ if ([System.IO.Path]::IsPathRooted($Path)) {
+ return $Path
+ }
+ return (Join-Path (Get-Location).Path $Path)
+}
+
+# Resolve the package version (default: the version pinned for the build in
+# Directory.Packages.props, so installers use the same Inno Setup as the build).
+if (-not $Version) {
+ $propsPath = Join-Path (Get-RepoRoot) 'Directory.Packages.props'
+ if (-not (Test-Path -LiteralPath $propsPath)) {
+ Write-Error "Central package versions file '$propsPath' not found"
+ }
+ [xml]$props = Get-Content -LiteralPath $propsPath
+ $Version = ($props.Project.ItemGroup.PackageVersion |
+ Where-Object { $_.Include -eq 'Tools.InnoSetup' }).Version
+ if (-not $Version) {
+ Write-Error "Tools.InnoSetup version not found in '$propsPath'"
+ }
+}
+
+# Resolve the download directory (default: out/tools/innosetup).
+if ($OutputPath) {
+ $OutputPath = Get-AbsolutePath $OutputPath
+} else {
+ $OutputPath = Join-Path (Get-RepoRoot) 'out' 'tools' 'innosetup'
+}
+
+Write-Information "Downloading Inno Setup $Version to '$OutputPath'..."
+
+# Display the download output on the host without letting it leak into this
+# script's output stream (so the returned object is the only success output).
+dotnet package download "Tools.InnoSetup@$Version" --output $OutputPath | Out-Host
+if ($LASTEXITCODE -ne 0) {
+ Write-Error "dotnet package download failed (exit $LASTEXITCODE)"
+}
+
+# 'dotnet package download' extracts each package to
+#