diff --git a/NuGet.config b/NuGet.config
index 16fb15a139..2efa2bad78 100644
--- a/NuGet.config
+++ b/NuGet.config
@@ -4,7 +4,7 @@
-
+
diff --git a/eng/Version.Details.props b/eng/Version.Details.props
index aae2a497b4..e88558585b 100644
--- a/eng/Version.Details.props
+++ b/eng/Version.Details.props
@@ -6,9 +6,9 @@ This file should be imported by eng/Versions.props
- 10.0.0-beta.26412.103
- 2.0.12
- 10.0.12
+ 10.0.0-beta.26505.127
+ 2.0.14
+ 10.0.14
diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml
index f2e05c29ea..52739089c9 100644
--- a/eng/Version.Details.xml
+++ b/eng/Version.Details.xml
@@ -1,20 +1,20 @@
-
+
-
+ https://github.com/dotnet/dotnet
- 493580a515a01970cfe1da2c7dd589efbaf36996
+ e4defac76e25a9168747171ce6ccf525dd916411
-
+ https://github.com/dotnet/dotnet
- 493580a515a01970cfe1da2c7dd589efbaf36996
+ e4defac76e25a9168747171ce6ccf525dd916411
-
+ https://github.com/dotnet/dotnet
- 493580a515a01970cfe1da2c7dd589efbaf36996
+ e4defac76e25a9168747171ce6ccf525dd916411
diff --git a/eng/Versions.props b/eng/Versions.props
index e0948daf5a..6217264d66 100644
--- a/eng/Versions.props
+++ b/eng/Versions.props
@@ -3,7 +3,7 @@
- 10.0.112
+ 10.0.114servicing
diff --git a/eng/common/Get-GitHubAppToken.ps1 b/eng/common/Get-GitHubAppToken.ps1
index 6b5899d7a2..ec005e487c 100644
--- a/eng/common/Get-GitHubAppToken.ps1
+++ b/eng/common/Get-GitHubAppToken.ps1
@@ -1,13 +1,11 @@
# Mints a short-lived GitHub App installation access token by signing a JWT
-# with a private key stored in Azure Key Vault (RSA, RS256). The signed JWT is
-# exchanged with the GitHub API for a token scoped to a single installation.
+# with an RSA private key (RS256). The signed JWT is exchanged with the GitHub
+# API for a token scoped to a single installation.
#
# Requirements:
-# - A GitHub App whose private key has been uploaded into Key Vault as an RSA
-# key (the PEM converted to a Key Vault *key*, NOT stored as a secret).
-# - The caller (the federated Azure service connection used to run this script)
-# must have the `Key Vault Crypto User` role (or at minimum the `Sign`
-# action) on that key.
+# - A GitHub App ID and PEM private key stored as Azure Key Vault secrets.
+# - The federated Azure service connection running this script must have
+# `Get` access to those two secrets.
# - The App must be installed on the target organization/account
# (`InstallationOwner`) with the permissions/repositories it needs.
#
@@ -16,17 +14,17 @@
[CmdletBinding()]
param(
- # Name of the Key Vault that holds the GitHub App's RSA signing key.
+ # Name of the Key Vault holding the GitHub App credentials.
[Parameter(Mandatory = $true)]
[string] $KeyVaultName,
- # Name of the RSA key inside the Key Vault (the App's private key).
+ # Secret Manager projection containing the GitHub App ID.
[Parameter(Mandatory = $true)]
- [string] $KeyName,
+ [string] $AppIdSecretName,
- # The GitHub App's Client ID (the value to put in the `iss` JWT claim).
+ # Secret Manager projection containing the PEM private key.
[Parameter(Mandatory = $true)]
- [string] $AppClientId,
+ [string] $AppPrivateKeySecretName,
# Login of the organization or user account whose installation we should
# mint the token for (e.g. `dotnet`, `microsoft`).
@@ -39,16 +37,69 @@ param(
[Parameter(Mandatory = $false)]
[string] $OutputVariableName
)
-
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
. $PSScriptRoot\pipeline-logging-functions.ps1
+if ($KeyVaultName -notmatch '^[A-Za-z][A-Za-z0-9-]{1,22}[A-Za-z0-9]$' -or $KeyVaultName.Contains('--')) {
+ Write-PipelineTelemetryError -Category 'Build' -Message "KeyVaultName '$KeyVaultName' is not a valid Azure Key Vault name."
+ exit 1
+}
+
function ConvertTo-Base64Url([byte[]] $bytes) {
return [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
}
+$previousNativeCommandErrorPreference = $PSNativeCommandUseErrorActionPreference
+try {
+ # Azure CLI can emit non-fatal Python warnings to stderr.
+ $PSNativeCommandUseErrorActionPreference = $false
+ $keyVaultAccessToken = az account get-access-token `
+ --resource https://vault.azure.net `
+ --query accessToken `
+ --output tsv `
+ --only-show-errors
+ $tokenExitCode = $LASTEXITCODE
+}
+catch {
+ Write-PipelineTelemetryError -Category 'Build' -Message "Failed to acquire an Azure Key Vault access token: $_"
+ exit 1
+}
+finally {
+ $PSNativeCommandUseErrorActionPreference = $previousNativeCommandErrorPreference
+}
+if ($tokenExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($keyVaultAccessToken)) {
+ Write-PipelineTelemetryError -Category 'Build' -Message "'az account get-access-token' exited with code $tokenExitCode while acquiring an Azure Key Vault access token."
+ exit 1
+}
+
+function Get-KeyVaultSecret([string] $SecretName) {
+ # Use the data-plane REST API because `az keyvault secret show` can fail
+ # with Errno 22 on hosted Windows agents when reading these projections.
+ $escapedSecretName = [Uri]::EscapeDataString($SecretName)
+ $secretUri = "https://$KeyVaultName.vault.azure.net/secrets/$escapedSecretName`?api-version=7.4"
+ try {
+ $response = Invoke-RestMethod `
+ -Uri $secretUri `
+ -Headers @{ Authorization = "Bearer $keyVaultAccessToken" } `
+ -Method Get
+ }
+ catch {
+ Write-PipelineTelemetryError -Category 'Build' -Message "Failed to read secret '$SecretName' from vault '$KeyVaultName': $_. Verify the secret exists and the service connection has 'Key Vault Secrets User' access to it."
+ exit 1
+ }
+ if ([string]::IsNullOrWhiteSpace($response.value)) {
+ Write-PipelineTelemetryError -Category 'Build' -Message "Secret '$SecretName' in vault '$KeyVaultName' is empty."
+ exit 1
+ }
+ return [string] $response.value
+}
+
+Write-Host "Reading GitHub App credentials from vault '$KeyVaultName'..."
+$appId = Get-KeyVaultSecret $AppIdSecretName
+$privateKey = Get-KeyVaultSecret $AppPrivateKeySecretName
+
# Build JWT header and payload. Use [ordered] hashtables so JSON
# serialization is deterministic.
$jwtHeader = [ordered]@{
@@ -59,46 +110,38 @@ $now = [System.DateTimeOffset]::UtcNow
$jwtPayload = [ordered]@{
iat = $now.AddMinutes(-1).ToUnixTimeSeconds()
exp = $now.AddMinutes(5).ToUnixTimeSeconds()
- iss = $AppClientId
+ iss = $appId
}
$headerEncoded = ConvertTo-Base64Url ([System.Text.Encoding]::UTF8.GetBytes(($jwtHeader | ConvertTo-Json -Compress)))
$payloadEncoded = ConvertTo-Base64Url ([System.Text.Encoding]::UTF8.GetBytes(($jwtPayload | ConvertTo-Json -Compress)))
$signingInput = "$headerEncoded.$payloadEncoded"
-# Key Vault `sign` expects the *digest* (base64), not the raw bytes.
-$sha256 = [System.Security.Cryptography.SHA256]::Create()
-$digestBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($signingInput))
-$digestBase64 = [Convert]::ToBase64String($digestBytes)
+$sha256 = [System.Security.Cryptography.SHA256]::Create()
+try {
+ $digestBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($signingInput))
+}
+finally {
+ $sha256.Dispose()
+}
-Write-Host "Signing JWT with key '$KeyName' in vault '$KeyVaultName'..."
-$previousNativeCommandErrorPreference = $PSNativeCommandUseErrorActionPreference
+Write-Host 'Signing JWT with the GitHub App private key...'
+$rsa = [System.Security.Cryptography.RSA]::Create()
try {
- # Azure CLI can emit non-fatal Python warnings to stderr even when signing succeeds.
- # Use the exit code to determine success for this invocation.
- $PSNativeCommandUseErrorActionPreference = $false
- $signatureBase64 = az keyvault key sign `
- --vault-name $KeyVaultName `
- --name $KeyName `
- --algorithm RS256 `
- --digest $digestBase64 `
- --query signature `
- --output tsv `
- --only-show-errors
- $signExitCode = $LASTEXITCODE
+ $rsa.ImportFromPem($privateKey)
+ $signatureBytes = $rsa.SignHash(
+ $digestBytes,
+ [System.Security.Cryptography.HashAlgorithmName]::SHA256,
+ [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
+ $signatureUrl = ConvertTo-Base64Url $signatureBytes
}
catch {
- Write-PipelineTelemetryError -Category 'Build' -Message "Failed to sign the JWT via Key Vault (key '$KeyName', vault '$KeyVaultName'): $_. Verify the service connection identity has the 'Key Vault Crypto User' role (Sign action) on the key."
+ Write-PipelineTelemetryError -Category 'Build' -Message "Failed to sign the GitHub App JWT with the supplied private key: $_"
exit 1
}
finally {
- $PSNativeCommandUseErrorActionPreference = $previousNativeCommandErrorPreference
+ $rsa.Dispose()
}
-if ($signExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($signatureBase64)) {
- Write-PipelineTelemetryError -Category 'Build' -Message "'az keyvault key sign' exited with code $signExitCode for key '$KeyName' in vault '$KeyVaultName'. Verify the service connection identity has the 'Key Vault Crypto User' role (Sign action) on the key."
- exit 1
-}
-$signatureUrl = $signatureBase64.Trim().TrimEnd('=').Replace('+', '-').Replace('/', '_')
$jwt = "$signingInput.$signatureUrl"
$headers = @{
@@ -110,27 +153,38 @@ $headers = @{
Write-Host "Looking up installation for '$InstallationOwner'..."
try {
- $installations = @()
+ $installations = [System.Collections.Generic.List[object]]::new()
$page = 1
do {
- $pageInstallations = @(Invoke-RestMethod `
+ $pageResponse = Invoke-RestMethod `
-Uri "https://api.github.com/app/installations?per_page=100&page=$page" `
-Headers $headers `
- -Method Get)
- $installations += $pageInstallations
+ -Method Get
+ $pageInstallationCount = 0
+ foreach ($installation in $pageResponse) {
+ $installations.Add($installation)
+ $pageInstallationCount++
+ }
$page++
- } while ($pageInstallations.Count -eq 100)
+ } while ($pageInstallationCount -eq 100)
}
catch {
- Write-PipelineTelemetryError -Category 'Build' -Message "Failed to list GitHub App installations: $_. The signed JWT may be invalid or the App's Client ID ('$AppClientId') may be incorrect."
+ Write-PipelineTelemetryError -Category 'Build' -Message "Failed to list GitHub App installations: $_. The signed JWT may be invalid or the App ID may be incorrect."
exit 1
}
-$installation = $installations | Where-Object { $_.account.login -ieq $InstallationOwner } | Select-Object -First 1
-if (-not $installation) {
+$matchingInstallations = @($installations | Where-Object { $_.account.login -ieq $InstallationOwner })
+if ($matchingInstallations.Count -eq 0) {
$found = ($installations | ForEach-Object { $_.account.login }) -join ', '
Write-PipelineTelemetryError -Category 'Build' -Message "No installation found for '$InstallationOwner'. App is installed on: $found"
exit 1
}
+if ($matchingInstallations.Count -ne 1) {
+ $matchingIds = ($matchingInstallations | ForEach-Object { $_.id }) -join ', '
+ Write-PipelineTelemetryError -Category 'Build' -Message "Found multiple installations for '$InstallationOwner': $matchingIds"
+ exit 1
+}
+$installation = $matchingInstallations[0]
+Write-Host "Using installation $($installation.id) for '$($installation.account.login)'."
try {
$tokenResponse = Invoke-RestMethod `
diff --git a/eng/common/core-templates/job/helix-job-monitor.yml b/eng/common/core-templates/job/helix-job-monitor.yml
new file mode 100644
index 0000000000..c95e3a48cd
--- /dev/null
+++ b/eng/common/core-templates/job/helix-job-monitor.yml
@@ -0,0 +1,322 @@
+parameters:
+# Maximum run time of the monitor job in minutes. Also used for --max-wait-minutes.
+- name: timeoutInMinutes
+ type: number
+ default: 360
+
+# Owner segment of the source repository (e.g. 'dotnet' for 'dotnet/runtime') passed via --organization.
+# Defaults to the owner segment of BUILD_REPOSITORY_NAME when empty.
+- name: organization
+ type: string
+ default: ''
+
+# Name of the source repository (e.g. 'runtime' for 'dotnet/runtime') passed via --repository.
+# Defaults to the repo segment of BUILD_REPOSITORY_NAME when empty.
+- name: repository
+ type: string
+ default: ''
+
+# Optional dependency list for the generated job.
+- name: dependsOn
+ type: object
+ default: []
+
+# Optional condition for the generated job.
+- name: condition
+ type: string
+ default: ''
+
+# Whether failures in the monitor job should allow the pipeline to continue.
+- name: continueOnError
+ type: boolean
+ default: false
+
+# NuGet package id of the Helix job monitor tool.
+- name: toolPackageId
+ type: string
+ default: Microsoft.DotNet.Helix.JobMonitor
+
+# Console command exposed by the installed tool package.
+- name: toolCommand
+ type: string
+ default: dotnet-helix-job-monitor
+
+# Optional explicit tool version. Only honored when 'toolNupkgArtifactName' is set; in the
+# default code path the version is taken from the consuming repo's .config/dotnet-tools.json.
+- name: toolVersion
+ type: string
+ default: ''
+
+# Base URI for the Helix service (--helix-base-uri).
+- name: helixBaseUri
+ type: string
+ default: https://helix.dot.net/
+
+# Helix API access token forwarded via HELIX_ACCESSTOKEN. Not forwarded when
+# useEntraAuthentication is true.
+- name: helixAccessToken
+ type: string
+ default: ''
+
+# Use a refreshable Entra credential instead of a PAT or anonymous access.
+- name: useEntraAuthentication
+ type: boolean
+ default: false
+
+# Explicit Entra scope for a custom Helix API host (--helix-entra-scope).
+# Production and staging scopes are inferred when omitted.
+- name: helixEntraScope
+ type: string
+ default: ''
+
+# Azure service connection ID authorized for Helix. Required when
+# useEntraAuthentication is true.
+- name: azureSubscription
+ type: string
+ default: ''
+
+# Polling interval in seconds (--polling-interval-seconds).
+- name: pollingIntervalSeconds
+ type: number
+ default: 30
+
+# Maximum number of work items whose results may be downloaded, parsed, and
+# uploaded concurrently.
+- name: testResultUploadParallelism
+ type: number
+ default: 48
+
+# When 'true' (the default), Helix work items that exit 0 but have failed AzDO test results
+# are treated as failed: they count toward the monitor's exit code and are resubmitted by a
+# later invocation's retry pass. Set to 'false' to fall back to exit-code-only outcomes.
+# Forwarded as --fail-on-failed-tests.
+- name: failWorkItemsWithFailedTests
+ type: boolean
+ default: true
+
+# When true, allow the monitor to succeed when this stage produces no Helix jobs in any attempt.
+# Forwarded as --allow-no-helix-jobs.
+- name: allowNoHelixJobs
+ type: boolean
+ default: false
+
+# When true (the default), test results are reported to Azure DevOps using the fully qualified test
+# name (Namespace.Type.Method) as the stable automatedTestName and the visible title is qualified as
+# well (--use-fully-qualified-test-name). Set to false to preserve framework-provided display names.
+- name: useFullyQualifiedTestName
+ type: boolean
+ default: true
+
+# Controls per-test output attachments. Defaults to Failed.
+- name: testResultAttachmentMode
+ type: string
+ default: Failed
+ values:
+ - Failed
+ - All
+ - None
+
+# Advanced: optional pipeline artifact (produced earlier in this run) that contains the tool
+# nupkg. When set, the artifact is downloaded and the tool is installed from the nupkg into
+# a local tool-path; this bypasses the repo's .config/dotnet-tools.json manifest and is
+# primarily intended for the Arcade repository itself, where the Helix job monitor tool is
+# built in the same pipeline that runs this template.
+#
+# When this parameter is empty (the default), the consuming repository must declare the tool
+# in its .config/dotnet-tools.json manifest (alongside other local .NET tools); the template
+# will check out the repo and run 'dotnet tool restore' to install the version pinned there.
+- name: toolNupkgArtifactName
+ type: string
+ default: ''
+
+# Advanced: sub-path within the downloaded artifact where the tool nupkg is located. Defaults
+# to the standard Arcade non-shipping packages location for a Release build (relative to the
+# pipeline artifact root, which is itself the build's 'artifacts' directory).
+- name: toolNupkgArtifactSubPath
+ type: string
+ default: 'packages/Release/NonShipping'
+
+jobs:
+- job: HelixJobMonitor
+ displayName: Monitor Helix Jobs
+ timeoutInMinutes: ${{ parameters.timeoutInMinutes }}
+ continueOnError: ${{ parameters.continueOnError }}
+ ${{ if ne(length(parameters.dependsOn), 0) }}:
+ dependsOn: ${{ parameters.dependsOn }}
+ ${{ if ne(parameters.condition, '') }}:
+ condition: ${{ parameters.condition }}
+ pool:
+ ${{ if eq(variables['System.TeamProject'], 'public') }}:
+ name: $(DncEngPublicBuildPool)
+ os: linux
+ demands: ImageOverride -equals build.azurelinux.3.amd64.open
+ ${{ else }}:
+ name: $(DncEngInternalBuildPool)
+ os: linux
+ demands: ImageOverride -equals build.azurelinux.3.amd64
+ steps:
+ - checkout: self
+ fetchDepth: 1
+
+ - ${{ if and(eq(parameters.useEntraAuthentication, true), eq(parameters.azureSubscription, '')) }}:
+ - pwsh: throw "azureSubscription must be set when useEntraAuthentication is true."
+ displayName: Validate Helix Entra authentication
+
+ - ${{ if eq(parameters.useEntraAuthentication, true) }}:
+ - task: AzureCLI@2
+ displayName: Initialize Helix Entra authentication
+ inputs:
+ azureSubscription: ${{ parameters.azureSubscription }}
+ addSpnToEnvironment: true
+ scriptType: pscore
+ scriptLocation: inlineScript
+ inlineScript: |
+ if ([string]::IsNullOrWhiteSpace($env:servicePrincipalId) -or [string]::IsNullOrWhiteSpace($env:tenantId)) {
+ throw "The Helix Azure service connection did not provide a service principal or tenant ID."
+ }
+
+ Write-Host "##vso[task.setvariable variable=HelixEntraClientId]$env:servicePrincipalId"
+ Write-Host "##vso[task.setvariable variable=HelixEntraTenantId]$env:tenantId"
+
+ - ${{ if ne(parameters.toolNupkgArtifactName, '') }}:
+ - task: DownloadPipelineArtifact@2
+ displayName: Download Helix Job Monitor artifact
+ inputs:
+ buildType: current
+ artifactName: ${{ parameters.toolNupkgArtifactName }}
+ itemPattern: '${{ parameters.toolNupkgArtifactSubPath }}/${{ parameters.toolPackageId }}.*.nupkg'
+ targetPath: $(Agent.TempDirectory)/helix-job-monitor-nupkg
+
+ - bash: |
+ set -euo pipefail
+
+ toolPath="$AGENT_TEMPDIRECTORY/helix-job-monitor-tool"
+ mkdir -p "$toolPath"
+
+ packageId='${{ parameters.toolPackageId }}'
+ toolVersion='${{ parameters.toolVersion }}'
+ nupkgArtifactSubPath='${{ parameters.toolNupkgArtifactSubPath }}'
+ nupkgDir="$AGENT_TEMPDIRECTORY/helix-job-monitor-nupkg/$nupkgArtifactSubPath"
+
+ if [ ! -d "$nupkgDir" ]; then
+ echo "Expected nupkg directory '$nupkgDir' was not produced by the artifact download." >&2
+ exit 1
+ fi
+
+ nupkg=$(find "$nupkgDir" -maxdepth 1 -type f -name "$packageId.*.nupkg" | head -n 1)
+ if [ -z "$nupkg" ]; then
+ echo "No '$packageId.*.nupkg' found in '$nupkgDir'." >&2
+ exit 1
+ fi
+
+ # Derive the version from the nupkg filename so the local package is selected
+ # deterministically instead of resolving against any other configured feed.
+ nupkgBase=$(basename "$nupkg" .nupkg)
+ derivedVersion="${nupkgBase#${packageId}.}"
+ if [ -z "$toolVersion" ]; then
+ toolVersion="$derivedVersion"
+ fi
+
+ echo "Using locally built '$packageId' version '$toolVersion' from '$nupkgDir'."
+
+ # Create a minimal NuGet.config that only references the local nupkg directory.
+ # This avoids conflicts with the repo's package source mapping which blocks --add-source.
+ toolNugetConfig="$AGENT_TEMPDIRECTORY/helix-job-monitor-nuget.config"
+ printf '\n\n \n \n \n \n\n' "$nupkgDir" > "$toolNugetConfig"
+
+ pushd "$(Build.SourcesDirectory)" > /dev/null
+ ./eng/common/dotnet.sh tool install \
+ --tool-path "$toolPath" "$packageId" \
+ --version "$toolVersion" \
+ --configfile "$toolNugetConfig"
+
+ # Locate the tool DLL so the run step can invoke it via ./eng/common/dotnet.sh exec.
+ toolDll=$(find "$toolPath/.store" -path '*/tools/*/any/*.deps.json' -type f | head -n 1)
+ toolDll="${toolDll%.deps.json}.dll"
+ if [ ! -f "$toolDll" ]; then
+ echo "Could not find tool DLL in '$toolPath/.store'." >&2
+ exit 1
+ fi
+
+ echo "Tool DLL: $toolDll"
+ echo "##vso[task.setvariable variable=HelixJobMonitorDll]$toolDll"
+ displayName: Install Helix Job Monitor
+
+ - ${{ else }}:
+ - bash: ./eng/common/dotnet.sh tool restore
+ displayName: Restore Helix Job Monitor
+
+ - bash: |
+ set -euo pipefail
+
+ toolArgs=(
+ --helix-base-uri '${{ parameters.helixBaseUri }}'
+ --use-entra-authentication '${{ parameters.useEntraAuthentication }}'
+ --helix-entra-scope '${{ parameters.helixEntraScope }}'
+ --polling-interval-seconds '${{ parameters.pollingIntervalSeconds }}'
+ --fail-on-failed-tests '${{ parameters.failWorkItemsWithFailedTests }}'
+ --allow-no-helix-jobs '${{ parameters.allowNoHelixJobs }}'
+ --use-fully-qualified-test-name '${{ parameters.useFullyQualifiedTestName }}'
+ --max-wait-minutes "$((${{ parameters.timeoutInMinutes }} - 5))" # Set the tool's timeout slightly lower than the Azure DevOps job timeout to allow it to exit gracefully.
+ --stage-name '$(System.StageName)'
+ --stage-attempt '$(System.StageAttempt)'
+ --job-attempt '$(System.JobAttempt)'
+ --test-result-upload-parallelism '${{ parameters.testResultUploadParallelism }}'
+ )
+
+ organization='${{ parameters.organization }}'
+ repository='${{ parameters.repository }}'
+ testResultAttachmentMode='${{ parameters.testResultAttachmentMode }}'
+
+ # Fall back to Azure DevOps-provided environment variables when the caller did not
+ # supply organization / repository explicitly. BUILD_REPOSITORY_NAME is typically
+ # 'owner/repo' for GitHub-backed builds and 'owner-repo' for internal builds.
+ if [ -z "$organization" ] || [ -z "$repository" ]; then
+ buildRepoName="${BUILD_REPOSITORY_NAME:-}"
+ if [ -n "$buildRepoName" ] && [[ "$buildRepoName" == */* ]]; then
+ repoOwner="${buildRepoName%%/*}"
+ repoName="${buildRepoName#*/}"
+ elif [ -n "$buildRepoName" ] && [[ "$buildRepoName" == *-* ]]; then
+ repoOwner="${buildRepoName%%-*}"
+ repoName="${buildRepoName#*-}"
+ fi
+
+ if [ -n "${repoOwner:-}" ] && [ -n "${repoName:-}" ]; then
+ if [ -z "$organization" ]; then organization="$repoOwner"; fi
+ if [ -z "$repository" ]; then repository="$repoName"; fi
+ fi
+ fi
+
+ if [ -n "$organization" ]; then toolArgs+=( --organization "$organization" ); fi
+ if [ -n "$repository" ]; then toolArgs+=( --repository "$repository" ); fi
+ if [ -n "$testResultAttachmentMode" ]; then
+ toolArgs+=( --test-result-attachment-mode "$testResultAttachmentMode" )
+ fi
+
+ # Build.Reason and Build.SourceBranch are required to derive the Helix source filter
+ # the same way the Helix SDK submitter does (PR -> 'pr', internal -> 'official',
+ # otherwise -> 'ci'). Without these, manually-queued / scheduled / CI builds would
+ # be looked up under the wrong source prefix and find zero jobs.
+ toolArgs+=( --build-reason "$(Build.Reason)" )
+ toolArgs+=( --source-branch "$(Build.SourceBranch)" )
+
+ if [ -n '${{ parameters.toolNupkgArtifactName }}' ]; then
+ # Tool was installed from a local nupkg; run the DLL via the repo-local dotnet.
+ export DOTNET_ROOT="$(Build.SourcesDirectory)/.dotnet"
+ ./eng/common/dotnet.sh exec "$(HelixJobMonitorDll)" "${toolArgs[@]}"
+ else
+ # Tool was restored from the local .config/dotnet-tools.json manifest; invoke it
+ # through the manifest from the repo root.
+ pushd "$BUILD_SOURCESDIRECTORY" > /dev/null
+ trap 'popd > /dev/null' EXIT
+ ./eng/common/dotnet.sh tool run '${{ parameters.toolCommand }}' -- "${toolArgs[@]}"
+ fi
+ displayName: Monitor Helix Jobs
+ env:
+ SYSTEM_ACCESSTOKEN: $(System.AccessToken)
+ ${{ if eq(parameters.useEntraAuthentication, false) }}:
+ HELIX_ACCESSTOKEN: ${{ parameters.helixAccessToken }}
+ ${{ if eq(parameters.useEntraAuthentication, true) }}:
+ AZURESUBSCRIPTION_CLIENT_ID: $(HelixEntraClientId)
+ AZURESUBSCRIPTION_TENANT_ID: $(HelixEntraTenantId)
+ AZURESUBSCRIPTION_SERVICE_CONNECTION_ID: ${{ parameters.azureSubscription }}
diff --git a/eng/common/core-templates/job/job.yml b/eng/common/core-templates/job/job.yml
index eaed6d87e6..a64f1c496a 100644
--- a/eng/common/core-templates/job/job.yml
+++ b/eng/common/core-templates/job/job.yml
@@ -109,7 +109,8 @@ jobs:
- name: ${{ pair.key }}
value: ${{ pair.value }}
- # DotNet-HelixApi-Access provides 'HelixApiAccessToken' for internal builds
+ # DotNet-HelixApi-Access provides 'HelixApiAccessToken' for internal builds.
+ # Entra-enabled Helix templates do not forward this value to their processes.
- ${{ if and(eq(parameters.enableTelemetry, 'true'), eq(parameters.runAsPublic, 'false'), ne(variables['System.TeamProject'], 'public'), notin(variables['Build.Reason'], 'PullRequest')) }}:
- group: DotNet-HelixApi-Access
diff --git a/eng/common/core-templates/job/onelocbuild.yml b/eng/common/core-templates/job/onelocbuild.yml
index b28af6613c..adcdfa1753 100644
--- a/eng/common/core-templates/job/onelocbuild.yml
+++ b/eng/common/core-templates/job/onelocbuild.yml
@@ -5,23 +5,14 @@ parameters:
# Optional: A defined YAML pool - https://docs.microsoft.com/en-us/azure/devops/pipelines/yaml-schema?view=vsts&tabs=schema#pool
pool: ''
- CeapexPat: $(dn-bot-ceapex-package-r) # PAT for the loc AzDO instance https://dev.azure.com/ceapex
- GithubPat: $(BotAccount-dotnet-bot-repo-PAT)
-
- # Service connection for WIF-based Entra authentication to ceapex feeds (replaces CeapexPat).
- # When set, dnceng/internal builds acquire a federated Entra token instead of using a PAT.
- # All other projects (e.g. DevDiv, public), where this dnceng-scoped service connection does not
- # exist, and any pipeline that sets this to '' fall back to PAT-based auth via the CeapexPat parameter.
+ # Project-scoped WIF service connection for Ceapex feed authentication.
CeapexServiceConnection: 'dnceng-onelocbuild-ceapex'
- # GitHub App authentication for the OneLoc check-in PR (dnceng/internal only).
- # The infrastructure identifiers are centralized here and the App path is enabled by default.
- # DevDiv requires its own project-scoped service connection before this path can be enabled there.
- UseGitHubAppAuthentication: true
+ # GitHub App authentication for the OneLoc check-in PR.
GitHubAppServiceConnection: 'dnceng-oneloc-githubapp'
- GitHubAppClientId: 'Iv23lijBU8x3gc9lDOc9'
GitHubAppKeyVaultName: 'EngKeyVault'
- GitHubAppKeyName: 'oneloc-localization-app-key'
+ GitHubAppIdSecretName: 'oneloc-localization-app-app-id'
+ GitHubAppPrivateKeySecretName: 'oneloc-localization-app-app-private-key'
SourcesDirectory: $(System.DefaultWorkingDirectory)
CreatePr: true
@@ -48,7 +39,6 @@ jobs:
displayName: OneLocBuild${{ parameters.JobNameSuffix }}
variables:
- - group: OneLocBuildVariables # Contains the CeapexPat and GithubPat
- name: _GenerateLocProjectArguments
value: -SourcesDirectory ${{ parameters.SourcesDirectory }}
-LanguageSet "${{ parameters.LanguageSet }}"
@@ -79,6 +69,12 @@ jobs:
steps:
- ${{ if eq(parameters.is1ESPipeline, '') }}:
- 'Illegal entry point, is1ESPipeline is not defined. Repository yaml should not directly reference templates in core-templates folder.': error
+ - ${{ if and(ne(variables['System.TeamProject'], 'internal'), ne(variables['System.TeamProject'], 'DevDiv')) }}:
+ - 'OneLocBuild is supported only in dnceng/internal and DevDiv/DevDiv.': error
+ - ${{ if eq(parameters.CeapexServiceConnection, '') }}:
+ - 'CeapexServiceConnection must identify a WIF service connection.': error
+ - ${{ if and(eq(parameters.RepoType, 'gitHub'), eq(parameters.GitHubAppServiceConnection, '')) }}:
+ - 'GitHubAppServiceConnection must identify a WIF service connection for GitHub repositories.': error
- ${{ if ne(parameters.SkipLocProjectJsonGeneration, 'true') }}:
- task: Powershell@2
@@ -88,25 +84,25 @@ jobs:
displayName: Generate LocProject.json
condition: ${{ parameters.condition }}
- # Acquire an Entra token for ceapex feed access via WIF (dnceng/internal only).
- # All other projects use PAT-based auth, since the ceapex service connection is scoped to dnceng/internal.
- - ${{ if and(ne(parameters.CeapexServiceConnection, ''), eq(variables['System.TeamProject'], 'internal')) }}:
- - template: /eng/common/templates/steps/get-federated-access-token.yml
- parameters:
- federatedServiceConnection: ${{ parameters.CeapexServiceConnection }}
- outputVariableName: 'CeapexEntraToken'
- condition: ${{ parameters.condition }}
+ # Acquire a short-lived Entra token for Ceapex feed access.
+ - template: /eng/common/templates/steps/get-federated-access-token.yml
+ parameters:
+ federatedServiceConnection: ${{ parameters.CeapexServiceConnection }}
+ outputVariableName: 'CeapexEntraToken'
+ condition: ${{ parameters.condition }}
- # Mint a short-lived GitHub App installation token for the loc check-in PR (dnceng/internal only).
- # All other projects fall back to PAT-based auth, since the app service connection is scoped to dnceng/internal.
- - ${{ if and(eq(parameters.RepoType, 'gitHub'), eq(parameters.UseGitHubAppAuthentication, true), eq(variables['System.TeamProject'], 'internal')) }}:
+ # Mint a short-lived GitHub App installation token for the loc check-in PR.
+ - ${{ if eq(parameters.RepoType, 'gitHub') }}:
- template: /eng/common/core-templates/steps/get-github-app-token.yml
parameters:
is1ESPipeline: ${{ parameters.is1ESPipeline }}
- azureSubscription: ${{ parameters.GitHubAppServiceConnection }}
+ ${{ if and(eq(variables['System.TeamProject'], 'DevDiv'), eq(parameters.GitHubAppServiceConnection, 'dnceng-oneloc-githubapp')) }}:
+ azureSubscription: 'devdiv-oneloc-githubapp'
+ ${{ else }}:
+ azureSubscription: ${{ parameters.GitHubAppServiceConnection }}
keyVaultName: ${{ parameters.GitHubAppKeyVaultName }}
- keyName: ${{ parameters.GitHubAppKeyName }}
- appClientId: ${{ parameters.GitHubAppClientId }}
+ appIdSecretName: ${{ parameters.GitHubAppIdSecretName }}
+ appPrivateKeySecretName: ${{ parameters.GitHubAppPrivateKeySecretName }}
installationOwner: ${{ parameters.GitHubOrg }}
outputVariableName: 'GitHubAppInstallationToken'
condition: ${{ parameters.condition }}
@@ -126,16 +122,10 @@ jobs:
isUseLfLineEndingsSelected: ${{ parameters.UseLfLineEndings }}
isShouldReusePrSelected: ${{ parameters.ReusePr }}
packageSourceAuth: patAuth
- ${{ if and(ne(parameters.CeapexServiceConnection, ''), eq(variables['System.TeamProject'], 'internal')) }}:
- patVariable: $(CeapexEntraToken)
- ${{ if or(eq(parameters.CeapexServiceConnection, ''), ne(variables['System.TeamProject'], 'internal')) }}:
- patVariable: ${{ parameters.CeapexPat }}
+ patVariable: $(CeapexEntraToken)
${{ if eq(parameters.RepoType, 'gitHub') }}:
repoType: ${{ parameters.RepoType }}
- ${{ if and(eq(parameters.UseGitHubAppAuthentication, true), eq(variables['System.TeamProject'], 'internal')) }}:
- gitHubPatVariable: "$(GitHubAppInstallationToken)"
- ${{ if or(eq(parameters.UseGitHubAppAuthentication, false), ne(variables['System.TeamProject'], 'internal')) }}:
- gitHubPatVariable: "${{ parameters.GithubPat }}"
+ gitHubPatVariable: "$(GitHubAppInstallationToken)"
${{ if ne(parameters.MirrorRepo, '') }}:
isMirrorRepoSelected: true
gitHubOrganization: ${{ parameters.GitHubOrg }}
diff --git a/eng/common/core-templates/steps/get-github-app-token.yml b/eng/common/core-templates/steps/get-github-app-token.yml
index 6d42a48d3c..3eeb5a4c1b 100644
--- a/eng/common/core-templates/steps/get-github-app-token.yml
+++ b/eng/common/core-templates/steps/get-github-app-token.yml
@@ -1,13 +1,11 @@
# Mints a short-lived GitHub App installation access token by signing a JWT
-# with a private key stored in Azure Key Vault (RSA, RS256). The JWT is
-# exchanged with the GitHub API for a token scoped to a single installation.
+# with an RSA private key (RS256). The JWT is exchanged with the GitHub API
+# for a token scoped to a single installation.
#
# Requirements (per GitHub App you want to authenticate as):
-# - A GitHub App with its private key uploaded into Key Vault as an RSA key
-# (PEM converted to a key, NOT stored as a secret).
-# - The Azure service connection passed via `azureSubscription` must be
-# granted the `Key Vault Crypto User` role (or at minimum `Sign` action)
-# on that key.
+# - A GitHub App ID and PEM private key stored as Azure Key Vault secrets.
+# - The Azure service connection passed via `azureSubscription` must have
+# `Get` access to those two secrets.
# - The App must be installed on the target organization/account
# (`installationOwner`) with the permissions/repositories you need.
#
@@ -17,23 +15,18 @@
# enterprise classic-PAT lifetime policy.
parameters:
-# Azure DevOps service connection (federated) that can call
-# `az keyvault key sign` on the App's signing key.
+# Azure DevOps service connection (federated) that can read the App credentials.
- name: azureSubscription
type: string
-# Name of the Key Vault that holds the GitHub App's RSA signing key.
+# Name of the Key Vault holding Secret Manager's github-app-secret projections.
- name: keyVaultName
type: string
-# Name of the RSA key inside the Key Vault (the App's private key).
-- name: keyName
+- name: appIdSecretName
type: string
-# The GitHub App's Client ID (the value to put in the `iss` JWT claim).
-# Prefer this over the numeric App ID; GitHub accepts either, but Client ID
-# is the documented form going forward.
-- name: appClientId
+- name: appPrivateKeySecretName
type: string
# Login of the organization or user account whose installation we should
@@ -73,7 +66,7 @@ steps:
inlineScript: |
& "$(System.DefaultWorkingDirectory)/eng/common/Get-GitHubAppToken.ps1" `
-KeyVaultName '${{ parameters.keyVaultName }}' `
- -KeyName '${{ parameters.keyName }}' `
- -AppClientId '${{ parameters.appClientId }}' `
+ -AppIdSecretName '${{ parameters.appIdSecretName }}' `
+ -AppPrivateKeySecretName '${{ parameters.appPrivateKeySecretName }}' `
-InstallationOwner '${{ parameters.installationOwner }}' `
-OutputVariableName '${{ parameters.outputVariableName }}'
diff --git a/eng/common/core-templates/steps/send-to-helix.yml b/eng/common/core-templates/steps/send-to-helix.yml
index 68fa739c4a..3951e47104 100644
--- a/eng/common/core-templates/steps/send-to-helix.yml
+++ b/eng/common/core-templates/steps/send-to-helix.yml
@@ -4,12 +4,16 @@ parameters:
HelixType: 'tests/default/' # required -- Helix telemetry which identifies what type of data this is; should include "test" for clarity and must end in '/'
HelixBuild: $(Build.BuildNumber) # required -- the build number Helix will use to identify this -- automatically set to the AzDO build number
HelixTargetQueues: '' # required -- semicolon-delimited list of Helix queues to test on; see https://helix.dot.net/ for a list of queues
- HelixAccessToken: '' # required -- access token to make Helix API requests; should be provided by the appropriate variable group
+ HelixAccessToken: '' # optional -- legacy access token; not forwarded when HelixUseEntraAuthentication is true
+ HelixUseEntraAuthentication: false # optional -- use refreshable Entra authentication instead of a PAT or anonymous access
+ HelixEntraScope: '' # optional -- explicit Entra scope required for custom HelixBaseUri hosts
+ HelixAzureSubscription: '' # required when HelixUseEntraAuthentication is true -- Azure service connection ID authorized for Helix
HelixProjectPath: 'eng/common/helixpublish.proj' # optional -- path to the project file to build relative to BUILD_SOURCESDIRECTORY
HelixProjectArguments: '' # optional -- arguments passed to the build command
HelixConfiguration: '' # optional -- additional property attached to a job
HelixPreCommands: '' # optional -- commands to run before Helix work item execution
HelixPostCommands: '' # optional -- commands to run after Helix work item execution
+ UseHelixMonitor: false # optional -- true will submit Helix jobs configured for the standalone Helix Job Monitor (results are reported/waited on out-of-band; this step will not wait, and WaitForWorkItemCompletion will be overridden)
WorkItemDirectory: '' # optional -- a payload directory to zip up and send to Helix; requires WorkItemCommand; incompatible with XUnitProjects
WorkItemCommand: '' # optional -- a command to execute on the payload; requires WorkItemDirectory; incompatible with XUnitProjects
WorkItemTimeout: '' # optional -- a timeout in TimeSpan.Parse-ready value (e.g. 00:02:00) for the work item command; requires WorkItemDirectory; incompatible with XUnitProjects
@@ -31,7 +35,38 @@ parameters:
continueOnError: false # optional -- determines whether to continue the build if the step errors; defaults to false
steps:
- - powershell: 'powershell "$env:BUILD_SOURCESDIRECTORY\eng\common\msbuild.ps1 $env:BUILD_SOURCESDIRECTORY/${{ parameters.HelixProjectPath }} /restore /p:TreatWarningsAsErrors=false ${{ parameters.HelixProjectArguments }} /t:Test /bl:$env:BUILD_SOURCESDIRECTORY\artifacts\log\$env:BuildConfig\SendToHelix.binlog"'
+ - ${{ if and(eq(parameters.HelixUseEntraAuthentication, true), eq(parameters.HelixAzureSubscription, '')) }}:
+ - pwsh: throw "HelixAzureSubscription must be set when HelixUseEntraAuthentication is true."
+ displayName: Validate Helix Entra authentication
+ condition: ${{ parameters.condition }}
+
+ - ${{ if eq(parameters.HelixUseEntraAuthentication, true) }}:
+ - task: AzureCLI@2
+ displayName: Initialize Helix Entra authentication
+ inputs:
+ azureSubscription: ${{ parameters.HelixAzureSubscription }}
+ addSpnToEnvironment: true
+ scriptType: pscore
+ scriptLocation: inlineScript
+ inlineScript: |
+ if ([string]::IsNullOrWhiteSpace($env:servicePrincipalId) -or [string]::IsNullOrWhiteSpace($env:tenantId)) {
+ throw "The Helix Azure service connection did not provide a service principal or tenant ID."
+ }
+
+ Write-Host "##vso[task.setvariable variable=HelixEntraClientId]$env:servicePrincipalId"
+ Write-Host "##vso[task.setvariable variable=HelixEntraTenantId]$env:tenantId"
+ condition: ${{ parameters.condition }}
+
+ - powershell: >
+ $(Build.SourcesDirectory)\eng\common\msbuild.ps1
+ $(Build.SourcesDirectory)/${{ parameters.HelixProjectPath }}
+ /restore
+ /p:TreatWarningsAsErrors=false
+ /p:EnableHelixJobMonitor=${{ parameters.UseHelixMonitor }}
+ /p:HelixUseEntraAuthentication=${{ parameters.HelixUseEntraAuthentication }}
+ ${{ parameters.HelixProjectArguments }}
+ /t:Test
+ /bl:$(Build.SourcesDirectory)/artifacts/log/$(_BuildConfig)/SendToHelix.binlog
displayName: ${{ parameters.DisplayNamePrefix }} (Windows)
env:
BuildConfig: $(_BuildConfig)
@@ -40,7 +75,13 @@ steps:
HelixBuild: ${{ parameters.HelixBuild }}
HelixConfiguration: ${{ parameters.HelixConfiguration }}
HelixTargetQueues: ${{ parameters.HelixTargetQueues }}
- HelixAccessToken: ${{ parameters.HelixAccessToken }}
+ ${{ if eq(parameters.HelixUseEntraAuthentication, false) }}:
+ HelixAccessToken: ${{ parameters.HelixAccessToken }}
+ ${{ if eq(parameters.HelixUseEntraAuthentication, true) }}:
+ AZURESUBSCRIPTION_CLIENT_ID: $(HelixEntraClientId)
+ AZURESUBSCRIPTION_TENANT_ID: $(HelixEntraTenantId)
+ AZURESUBSCRIPTION_SERVICE_CONNECTION_ID: ${{ parameters.HelixAzureSubscription }}
+ HelixEntraScope: ${{ parameters.HelixEntraScope }}
HelixPreCommands: ${{ parameters.HelixPreCommands }}
HelixPostCommands: ${{ parameters.HelixPostCommands }}
WorkItemDirectory: ${{ parameters.WorkItemDirectory }}
@@ -61,7 +102,16 @@ steps:
SYSTEM_ACCESSTOKEN: $(System.AccessToken)
condition: and(${{ parameters.condition }}, eq(variables['Agent.Os'], 'Windows_NT'))
continueOnError: ${{ parameters.continueOnError }}
- - script: $BUILD_SOURCESDIRECTORY/eng/common/msbuild.sh $BUILD_SOURCESDIRECTORY/${{ parameters.HelixProjectPath }} /restore /p:TreatWarningsAsErrors=false ${{ parameters.HelixProjectArguments }} /t:Test /bl:$BUILD_SOURCESDIRECTORY/artifacts/log/$BuildConfig/SendToHelix.binlog
+ - script: >
+ $(Build.SourcesDirectory)/eng/common/msbuild.sh
+ $(Build.SourcesDirectory)/${{ parameters.HelixProjectPath }}
+ /restore
+ /p:TreatWarningsAsErrors=false
+ /p:EnableHelixJobMonitor=${{ parameters.UseHelixMonitor }}
+ /p:HelixUseEntraAuthentication=${{ parameters.HelixUseEntraAuthentication }}
+ ${{ parameters.HelixProjectArguments }}
+ /t:Test
+ /bl:$(Build.SourcesDirectory)/artifacts/log/$(_BuildConfig)/SendToHelix.binlog
displayName: ${{ parameters.DisplayNamePrefix }} (Unix)
env:
BuildConfig: $(_BuildConfig)
@@ -70,7 +120,13 @@ steps:
HelixBuild: ${{ parameters.HelixBuild }}
HelixConfiguration: ${{ parameters.HelixConfiguration }}
HelixTargetQueues: ${{ parameters.HelixTargetQueues }}
- HelixAccessToken: ${{ parameters.HelixAccessToken }}
+ ${{ if eq(parameters.HelixUseEntraAuthentication, false) }}:
+ HelixAccessToken: ${{ parameters.HelixAccessToken }}
+ ${{ if eq(parameters.HelixUseEntraAuthentication, true) }}:
+ AZURESUBSCRIPTION_CLIENT_ID: $(HelixEntraClientId)
+ AZURESUBSCRIPTION_TENANT_ID: $(HelixEntraTenantId)
+ AZURESUBSCRIPTION_SERVICE_CONNECTION_ID: ${{ parameters.HelixAzureSubscription }}
+ HelixEntraScope: ${{ parameters.HelixEntraScope }}
HelixPreCommands: ${{ parameters.HelixPreCommands }}
HelixPostCommands: ${{ parameters.HelixPostCommands }}
WorkItemDirectory: ${{ parameters.WorkItemDirectory }}
diff --git a/global.json b/global.json
index ef41294682..95061b3152 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "10.0.111",
+ "version": "10.0.112",
"allowPrerelease": true,
"rollForward": "latestFeature",
"paths": [
@@ -10,10 +10,10 @@
"errorMessage": "The required .NET SDK wasn't found. Please run ./eng/common/dotnet.cmd/sh to install it."
},
"tools": {
- "dotnet": "10.0.111"
+ "dotnet": "10.0.112"
},
"msbuild-sdks": {
- "Microsoft.DotNet.Arcade.Sdk": "10.0.0-beta.26412.103",
+ "Microsoft.DotNet.Arcade.Sdk": "10.0.0-beta.26505.127",
"Microsoft.Build.NoTargets": "3.7.0"
}
}
diff --git a/src/Common/AzureDevOps/AzureDevOpsUrlParser.cs b/src/Common/AzureDevOps/AzureDevOpsUrlParser.cs
index dc8635a732..5d35ad8882 100644
--- a/src/Common/AzureDevOps/AzureDevOpsUrlParser.cs
+++ b/src/Common/AzureDevOps/AzureDevOpsUrlParser.cs
@@ -110,11 +110,17 @@ public static bool TryParseOnPremHttp(string relativeUrl, string virtualDirector
return true;
}
- public static bool TryParseHostedSsh(Uri uri, [NotNullWhen(true)]out string? account, [NotNullWhen(true)]out string? repositoryPath, [NotNullWhen(true)]out string? repositoryName)
+ public static bool TryParseHostedSsh(
+ Uri uri,
+ [NotNullWhen(true)] out string? account,
+ [NotNullWhen(true)] out string? repositoryPath,
+ [NotNullWhen(true)] out string? repositoryName,
+ out bool isUnsupportedFormat)
{
NullableDebug.Assert(uri != null);
account = repositoryPath = repositoryName = null;
+ isUnsupportedFormat = false;
// {"DefaultCollection"|""}/{repositoryPath}/"_ssh"/{"_full"|"_optimized"}/{repositoryName}
if (!UriUtilities.TrySplitRelativeUrl(uri.GetPath(), out var parts) || parts.Length == 0)
@@ -125,7 +131,7 @@ public static bool TryParseHostedSsh(Uri uri, [NotNullWhen(true)]out string? acc
// Check for v3 url format
if (parts[0] == "v3" &&
parts.Length >= 3 &&
- TryParsePath(parts, 2, type: null, out repositoryPath, out repositoryName) &&
+ TryParsePath(parts, startIndex: 2, type: null, out repositoryPath, out repositoryName) &&
repositoryPath != "")
{
// ssh://{user}@{domain}:{port}/v3/{account}/{repositoryPath}/{'_full'|'_optimized'|''}/{repositoryName}
@@ -136,8 +142,6 @@ public static bool TryParseHostedSsh(Uri uri, [NotNullWhen(true)]out string? acc
// ssh v1/v2 url formats
// ssh://{account}@vs-ssh.visualstudio.com/
- account = uri.UserInfo;
-
int index = 0;
if (StringComparer.OrdinalIgnoreCase.Equals(parts[0], "DefaultCollection"))
{
@@ -149,14 +153,15 @@ public static bool TryParseHostedSsh(Uri uri, [NotNullWhen(true)]out string? acc
// Failed to parse path
return false;
}
- }
- if (account.Length == 0)
- {
+ // The format uses SSH connection user name as an account name.
+ // It is no longer supported since GitOperations.GetRepositoryUrl strips the user info
+ // to prevent leaking credentials.
+ isUnsupportedFormat = true;
return false;
}
- return true;
+ return account.Length > 0;
}
public static bool TryParseOnPremSsh(Uri uri, [NotNullWhen(true)]out string? repositoryPath, [NotNullWhen(true)]out string? repositoryName)
diff --git a/src/Microsoft.Build.Tasks.Git.UnitTests/GitOperationsTests.cs b/src/Microsoft.Build.Tasks.Git.UnitTests/GitOperationsTests.cs
index 0832293988..e4ce1f9f03 100644
--- a/src/Microsoft.Build.Tasks.Git.UnitTests/GitOperationsTests.cs
+++ b/src/Microsoft.Build.Tasks.Git.UnitTests/GitOperationsTests.cs
@@ -224,23 +224,26 @@ public void GetRepositoryUrl_UnsupportedUrl(string kind)
}
[Theory]
- [InlineData("https://github.com/org/repo")]
- [InlineData("http://github.com/org/repo")]
- [InlineData("http://github.com:102/org/repo")]
- [InlineData("ssh://user@github.com/org/repo")]
- [InlineData("abc://user@github.com/org/repo")]
- public void NormalizeUrl_PlatformAgnostic1(string url)
+ [InlineData("https://github.com/org/repo", "https://github.com/org/repo")]
+ [InlineData("http://github.com/org/repo", "http://github.com/org/repo")]
+ [InlineData("http://github.com:102/org/repo", "http://github.com:102/org/repo")]
+ [InlineData("ssh://user@github.com/org/repo", "ssh://git@github.com/org/repo")] // "user" replaced with "git" in SSH URL
+ [InlineData("abc://user@github.com/org/repo", "abc://github.com/org/repo")]
+ public void NormalizeUrl_PlatformAgnostic1(string url, string expected)
{
- AssertEx.AreEqual(url, GitOperations.NormalizeUrl(url, s_root)?.AbsoluteUri);
+ AssertEx.AreEqual(expected, GitOperations.NormalizeUrl(url, s_root)?.AbsoluteUri);
}
[Theory]
[InlineData("http://?", null)]
[InlineData("https://github.com/org/repo/./.", "https://github.com/org/repo/")]
[InlineData("http://github.com/org/" + TestStrings.RepoName, "http://github.com/org/" + TestStrings.RepoNameFullyEscaped)]
- [InlineData("ssh://github.com/org/../repo", "ssh://github.com/repo")]
- [InlineData("ssh://github.com/%32/repo", "ssh://github.com/2/repo")]
- [InlineData("ssh://github.com/%3F/repo", "ssh://github.com/%3F/repo")]
+ [InlineData("ssh://github.com/org/../repo", "ssh://git@github.com/repo")]
+ [InlineData("ssh://github.com/%32/repo", "ssh://git@github.com/2/repo")]
+ [InlineData("ssh://github.com/%3F/repo", "ssh://git@github.com/%3F/repo")]
+ [InlineData(@"../.:./../../relative/path", null)]
+ [InlineData(@".:/../../relative/path", null)]
+ [InlineData(@"..:/../../relative/path", null)]
public void NormalizeUrl_PlatformAgnostic2(string url, string? expectedUrl)
{
AssertEx.AreEqual(expectedUrl, GitOperations.NormalizeUrl(url, s_root)?.AbsoluteUri);
@@ -259,24 +262,24 @@ public void NormalizeUrl_PlatformAgnostic2(string url, string? expectedUrl)
// [InlineData(@"../relative/path*<>|\0%00", @"file:///usr/src/a/relative/path*%3C%3E%7C/0%2500")] // https://github.com/dotnet/sourcelink/issues/439
[InlineData(@"../../../../relative/path", @"file:///relative/path")]
[InlineData(@"../.://../../relative/path", "file:///usr/src/a/relative/path")]
- [InlineData(@"../.:./../../relative/path", "ssh://../relative/path")]
- [InlineData(@".:/../../relative/path", "ssh://./relative/path")]
- [InlineData(@"..:/../../relative/path", "ssh://../relative/path")]
+ [InlineData(@"../.:./../../relative/path", null)]
+ [InlineData(@".:/../../relative/path", null)]
+ [InlineData(@"..:/../../relative/path", null)]
[InlineData(@"@:org/repo", @"file:///usr/src/a/b/@:org/repo")]
- public void NormalizeUrl_Unix(string url, string expectedUrl)
+ public void NormalizeUrl_Unix(string url, string? expectedUrl)
{
Assert.Equal(expectedUrl, GitOperations.NormalizeUrl(url, "/usr/src/a/b")?.AbsoluteUri);
}
[Theory]
- [InlineData("abc:org/repo", "ssh://abc/org/repo")]
- [InlineData("abc:org/x%20y", "ssh://abc/org/x%20y")]
- [InlineData("ABC:ORG/REPO/X/Y", "ssh://abc/ORG/REPO/X/Y")]
- [InlineData("github.com:org/repo", "ssh://github.com/org/repo")]
- [InlineData("git@github.com:org/repo", "ssh://git@github.com/org/repo")]
- [InlineData("@github.com:org/repo", "ssh://@github.com/org/repo")]
- [InlineData("http:x//y", "ssh://http/x//y")]
- public void GetRepositoryUrl_ScpSyntax(string url, string expectedUrl)
+ [InlineData("abc:org/repo", "ssh://git@abc/org/repo")]
+ [InlineData("abc:org/x%20y", "ssh://git@abc/org/x%20y")]
+ [InlineData("ABC:ORG/REPO/X/Y", "ssh://git@abc/ORG/REPO/X/Y")]
+ [InlineData("github.com:org/repo", "ssh://git@github.com/org/repo")]
+ [InlineData("user@github.com:org/repo", "ssh://git@github.com/org/repo")] // "user" replaced with "git" in SSH URL
+ [InlineData("@github.com:org/repo", "ssh://git@github.com/org/repo")]
+ [InlineData("http:x//y", "ssh://git@http/x//y")]
+ public void NormalizeUrl_ScpSyntax(string url, string expectedUrl)
{
Assert.Equal(expectedUrl, GitOperations.NormalizeUrl(url, s_root)?.AbsoluteUri);
}
@@ -398,7 +401,7 @@ public void GetSourceRoots_RepoWithoutCommitsWithSubmodules()
// URLs listed in .submodules are ignored (they are used by git submodule initialize to generate URLs stored in config).
AssertEx.Equal(new[]
{
- $@"'{_workingDir}{s}sub{s}1{s}' SourceControl='git' RevisionId='1111111111111111111111111111111111111111' NestedRoot='sub/1/' ContainingRoot='{_workingDir}{s}' ScmRepositoryUrl='ssh://github.com/sub-1'",
+ $@"'{_workingDir}{s}sub{s}1{s}' SourceControl='git' RevisionId='1111111111111111111111111111111111111111' NestedRoot='sub/1/' ContainingRoot='{_workingDir}{s}' ScmRepositoryUrl='ssh://git@github.com/sub-1'",
$@"'{_workingDir}{s}sub{s}3{s}' SourceControl='git' RevisionId='3333333333333333333333333333333333333333' NestedRoot='sub/3/' ContainingRoot='{_workingDir}{s}' ScmRepositoryUrl='https://github.com/sub-3'",
$@"'{_workingDir}{s}sub{s}6{s}' SourceControl='git' RevisionId='6666666666666666666666666666666666666666' NestedRoot='sub/6/' ContainingRoot='{_workingDir}{s}' ScmRepositoryUrl='https://github.com/sub-6'",
}, items.Select(TestUtilities.InspectSourceRoot));
diff --git a/src/Microsoft.Build.Tasks.Git/GitOperations.cs b/src/Microsoft.Build.Tasks.Git/GitOperations.cs
index cc0301ae11..b135b60d5e 100644
--- a/src/Microsoft.Build.Tasks.Git/GitOperations.cs
+++ b/src/Microsoft.Build.Tasks.Git/GitOperations.cs
@@ -164,7 +164,35 @@ internal static string ApplyInsteadOfUrlMapping(GitConfig config, string url)
private static bool IsSupportedScheme(string scheme)
=> scheme is "http" or "https" or "ssh" or "git";
+ // internal for testing
internal static Uri? NormalizeUrl(string url, string root)
+ {
+ var normalizedUrl = NormalizeUrlImpl(url, root);
+ if (normalizedUrl == null)
+ {
+ return null;
+ }
+
+ // remove user info to avoid embedding access tokens to build artifacts:
+ var builder = new UriBuilder(normalizedUrl)
+ {
+ // If user name is not specified in SSH URL, the local user name is used for connecting to the repo.
+ // Use "git" placeholder instead of a specific user name.
+ UserName = normalizedUrl.Scheme is "ssh" ? "git" : null,
+ Password = null,
+ };
+
+ try
+ {
+ return builder.Uri;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static Uri? NormalizeUrlImpl(string url, string root)
{
// Since git supports scp-like syntax for SSH URLs we convert it here,
// so that RepositoryUrl is actually a valid URL in that case.
diff --git a/src/SourceLink.AzureRepos.Git.UnitTests/AzureDevOpsUrlParserHostedTests.cs b/src/SourceLink.AzureRepos.Git.UnitTests/AzureDevOpsUrlParserHostedTests.cs
index 2284ff1ffd..a1fa02d7dd 100644
--- a/src/SourceLink.AzureRepos.Git.UnitTests/AzureDevOpsUrlParserHostedTests.cs
+++ b/src/SourceLink.AzureRepos.Git.UnitTests/AzureDevOpsUrlParserHostedTests.cs
@@ -95,37 +95,42 @@ public void TryParseHostedSsh_Error(string url)
}
[Theory]
- [InlineData("ssh://account@vs-ssh.visualstudio.com/project/_ssh/repo", "account", "project", "repo")]
- [InlineData("ssh://account@vs-ssh.visualstudio.com/project/team/_ssh/repo", "account", "project/team", "repo")]
- [InlineData("ssh://account@vs-ssh.visualstudio.com/DefaultCollection/project/_ssh/repo", "account", "project", "repo")]
- [InlineData("ssh://account@vs-ssh.visualstudio.com/DefaultCollection/project/team/_ssh/repo", "account", "project/team", "repo")]
- [InlineData("ssh://account@vs-ssh.visualstudio.com/DefaultCollection/project/team/_ssh/_full/repo", "account", "project/team", "repo")]
- [InlineData("ssh://account@vs-ssh.visualstudio.com/DefaultCollection/project/team/_ssh/_optimized/repo", "account", "project/team", "repo")]
- [InlineData("ssh://account@vs-ssh.visualstudio.com/DefaultCollection/_ssh/repo", "account", "", "repo")]
- [InlineData("ssh://account@vs-ssh.visualstudio.com/_ssh/repo", "account", "", "repo")]
-
- [InlineData("ssh://account@vs-ssh.vsts.me/project/_ssh/repo", "account", "project", "repo")]
- [InlineData("ssh://account@vs-ssh.vsts.me/project/team/_ssh/repo", "account", "project/team", "repo")]
- [InlineData("ssh://account@vs-ssh.vsts.me/DefaultCollection/project/_ssh/repo", "account", "project", "repo")]
- [InlineData("ssh://account@vs-ssh.vsts.me/DefaultCollection/project/team/_ssh/repo", "account", "project/team", "repo")]
- [InlineData("ssh://account@vs-ssh.vsts.me/DefaultCollection/project/team/_ssh/_full/repo", "account", "project/team", "repo")]
- [InlineData("ssh://account@vs-ssh.vsts.me/DefaultCollection/project/team/_ssh/_optimized/repo", "account", "project/team", "repo")]
- [InlineData("ssh://account@vs-ssh.vsts.me/DefaultCollection/_ssh/repo", "account", "", "repo")]
- [InlineData("ssh://account@vs-ssh.vsts.me/_ssh/repo", "account", "", "repo")]
-
- [InlineData("ssh://account@ssh.contoso.com/project/_ssh/repo", "account", "project", "repo")]
- [InlineData("ssh://account@ssh.contoso.com/project/team/_ssh/repo", "account", "project/team", "repo")]
- [InlineData("ssh://account@ssh.contoso.com/project/team/_ssh/_full/repo", "account", "project/team", "repo")]
- [InlineData("ssh://account@ssh.contoso.com/project/team/_ssh/_optimized/repo", "account", "project/team", "repo")]
+ [InlineData("ssh://account@vs-ssh.visualstudio.com/project/_ssh/repo")]
+ [InlineData("ssh://account@vs-ssh.visualstudio.com/project/team/_ssh/repo")]
+ [InlineData("ssh://account@vs-ssh.visualstudio.com/DefaultCollection/project/_ssh/repo")]
+ [InlineData("ssh://account@vs-ssh.visualstudio.com/DefaultCollection/project/team/_ssh/repo")]
+ [InlineData("ssh://account@vs-ssh.visualstudio.com/DefaultCollection/project/team/_ssh/_full/repo")]
+ [InlineData("ssh://account@vs-ssh.visualstudio.com/DefaultCollection/project/team/_ssh/_optimized/repo")]
+ [InlineData("ssh://account@vs-ssh.visualstudio.com/DefaultCollection/_ssh/repo")]
+ [InlineData("ssh://account@vs-ssh.visualstudio.com/_ssh/repo")]
+ [InlineData("ssh://account@vs-ssh.vsts.me/project/_ssh/repo")]
+ [InlineData("ssh://account@vs-ssh.vsts.me/project/team/_ssh/repo")]
+ [InlineData("ssh://account@vs-ssh.vsts.me/DefaultCollection/project/_ssh/repo")]
+ [InlineData("ssh://account@vs-ssh.vsts.me/DefaultCollection/project/team/_ssh/repo")]
+ [InlineData("ssh://account@vs-ssh.vsts.me/DefaultCollection/project/team/_ssh/_full/repo")]
+ [InlineData("ssh://account@vs-ssh.vsts.me/DefaultCollection/project/team/_ssh/_optimized/repo")]
+ [InlineData("ssh://account@vs-ssh.vsts.me/DefaultCollection/_ssh/repo")]
+ [InlineData("ssh://account@vs-ssh.vsts.me/_ssh/repo")]
+ [InlineData("ssh://account@ssh.contoso.com/project/_ssh/repo")]
+ [InlineData("ssh://account@ssh.contoso.com/project/team/_ssh/repo")]
+ [InlineData("ssh://account@ssh.contoso.com/project/team/_ssh/_full/repo")]
+ [InlineData("ssh://account@ssh.contoso.com/project/team/_ssh/_optimized/repo")]
+ [InlineData("ssh://account@vs-ssh.visualstudio.com/v3/_ssh/repo")]
+ [InlineData("ssh://account@vs-ssh.visualstudio.com/v3/team/_ssh/repo")]
+ public void TryParseHostedSshV1V2_Success(string url)
+ {
+ Assert.False(AzureDevOpsUrlParser.TryParseHostedSsh(
+ new Uri(url, UriKind.Absolute), out _, out _, out _, out var isUnsupportedFormat));
+ Assert.True(isUnsupportedFormat);
+ }
- [InlineData("ssh://account@vs-ssh.visualstudio.com/v3/_ssh/repo", "account", "v3", "repo")]
- [InlineData("ssh://account@vs-ssh.visualstudio.com/v3/team/_ssh/repo", "account", "v3/team", "repo")]
- public void TryParseHostedSshV1V2_Success(string url, string account, string repositoryPath, string repositoryName)
+ [Theory]
+ [InlineData("ssh://account@vs-ssh.visualstudio.com/v4/team/_ssh")]
+ public void TryParseHostedSshV1V2_Error(string url)
{
- Assert.True(AzureDevOpsUrlParser.TryParseHostedSsh(new Uri(url, UriKind.Absolute), out var actualAccount, out var actualRepositoryPath, out var actualRepositoryName));
- Assert.Equal(account, actualAccount);
- Assert.Equal(repositoryPath, actualRepositoryPath);
- Assert.Equal(repositoryName, actualRepositoryName);
+ Assert.False(AzureDevOpsUrlParser.TryParseHostedSsh(
+ new Uri(url, UriKind.Absolute), out _, out _, out _, out var isUnsupportedFormat));
+ Assert.False(isUnsupportedFormat);
}
[Theory]
@@ -145,7 +150,9 @@ public void TryParseHostedSshV1V2_Success(string url, string account, string rep
[InlineData("ssh://account1@ssh.contoso.com/v3/account2/project/team/_optimized/repo", "account2", "project/team", "repo")]
public void TryParseHostedSshV3_Success(string url, string account, string repositoryPath, string repositoryName)
{
- Assert.True(AzureDevOpsUrlParser.TryParseHostedSsh(new Uri(url, UriKind.Absolute), out var actualAccount, out var actualRepositoryPath, out var actualRepositoryName));
+ Assert.True(AzureDevOpsUrlParser.TryParseHostedSsh(
+ new Uri(url, UriKind.Absolute), out var actualAccount, out var actualRepositoryPath, out var actualRepositoryName, out var isUnsupportedFormat));
+ Assert.False(isUnsupportedFormat);
Assert.Equal(account, actualAccount);
Assert.Equal(repositoryPath, actualRepositoryPath);
Assert.Equal(repositoryName, actualRepositoryName);
diff --git a/src/SourceLink.AzureRepos.Git.UnitTests/TranslateRepositoryUrlsTests.cs b/src/SourceLink.AzureRepos.Git.UnitTests/TranslateRepositoryUrlsTests.cs
index 5ad83c5d2a..3f1a44a012 100644
--- a/src/SourceLink.AzureRepos.Git.UnitTests/TranslateRepositoryUrlsTests.cs
+++ b/src/SourceLink.AzureRepos.Git.UnitTests/TranslateRepositoryUrlsTests.cs
@@ -18,21 +18,21 @@ public void Translate()
var task = new TranslateRepositoryUrls()
{
BuildEngine = engine,
- RepositoryUrl = "ssh://account@vs-ssh.visualstudio.com/project/team/_ssh/repo",
+ RepositoryUrl = "ssh://vs-ssh.visualstudio.com/v3/account/project/team/repo",
IsSingleProvider = true,
SourceRoots = new[]
{
- new MockItem("/1/", KVP("SourceControl", "git"), KVP("ScmRepositoryUrl", "ssh://account@vs-ssh.visualstudio.com:22/project/team/_ssh/repo")), // ok
- new MockItem("/2/", KVP("SourceControl", "git"), KVP("ScmRepositoryUrl", "ssh://test@vs-ssh.visualstudio.com:22/project/_ssh/repo")), // ok
+ new MockItem("/1/", KVP("SourceControl", "git"), KVP("ScmRepositoryUrl", "ssh://vs-ssh.visualstudio.com:22/v3/account/project/team/repo")), // ok
+ new MockItem("/2/", KVP("SourceControl", "git"), KVP("ScmRepositoryUrl", "ssh://user@vs-ssh.visualstudio.com:22/v3/test/project/repo")), // ok
new MockItem("/3/", KVP("SourceControl", "git"), KVP("ScmRepositoryUrl", "ssh://user@vs-ssh.visualstudio.com:22/v3/account/project/team/repo")), // ok
- new MockItem("/4/", KVP("SourceControl", "git"), KVP("ScmRepositoryUrl", "ssh://account@vs-ssh.visualstudio.com/_ssh/repo")), // ok
- new MockItem("/5/", KVP("SourceControl", "git"), KVP("ScmRepositoryUrl", "ssh://account@ssh.contoso.com:22/project/team/_ssh/repo")), // ok
+ new MockItem("/4/", KVP("SourceControl", "git"), KVP("ScmRepositoryUrl", "ssh://vs-ssh.visualstudio.com/v3/account/project/repo")), // ok
+ new MockItem("/5/", KVP("SourceControl", "git"), KVP("ScmRepositoryUrl", "ssh://ssh.contoso.com:22/v3/account/project/team/repo")), // ok
new MockItem("/6/", KVP("SourceControl", "git"), KVP("ScmRepositoryUrl", "ssh://user@ssh.contoso.com/v3/account/project/team/repo")), // ok
- new MockItem("/7/", KVP("SourceControl", "tfvc"), KVP("ScmRepositoryUrl", "ssh://account@vs-ssh.visualstudio.com:22/project/team/_ssh/repo")), // different source control
- new MockItem("/8/", KVP("SourceControl", "git"), KVP("ScmRepositoryUrl", "ssh://account@contoso.com:22/project/team/_ssh/repo")), // no "vs-ssh." prefix
- new MockItem("/9/", KVP("SourceControl", "git"), KVP("ScmRepositoryUrl", "ssh://account@vs-ssh.contoso.com:22/project/team/_ssh/repo")), // known host, but not visualstudio.com
- new MockItem("/A/", KVP("SourceControl", "git"), KVP("ScmRepositoryUrl", "ssh://account@vs-ssh.contoso2.com:22/project/team/_ssh/repo")), // unknown host
- new MockItem("/B/", KVP("SourceControl", "git"), KVP("ScmRepositoryUrl", "ssh://account@vs-ssh.contoso.com:22/project/team/ZZZ/repo")), // bad format
+ new MockItem("/7/", KVP("SourceControl", "tfvc"), KVP("ScmRepositoryUrl", "ssh://vs-ssh.visualstudio.com:22/v3/account/project/team/repo")), // different source control
+ new MockItem("/8/", KVP("SourceControl", "git"), KVP("ScmRepositoryUrl", "ssh://contoso.com:22/v3/account/project/team/repo")), // no "vs-ssh." prefix
+ new MockItem("/9/", KVP("SourceControl", "git"), KVP("ScmRepositoryUrl", "ssh://vs-ssh.contoso.com:22/v3/account/project/team/repo")), // known host, but not visualstudio.com
+ new MockItem("/A/", KVP("SourceControl", "git"), KVP("ScmRepositoryUrl", "ssh://vs-ssh.contoso2.com:22/v3/account/project/team/repo")), // unknown host
+ new MockItem("/B/", KVP("SourceControl", "git"), KVP("ScmRepositoryUrl", "ssh://vs-ssh.contoso.com:22/v3/account/project/team/ZZZ/repo")), // bad format
},
Hosts = new[]
{
@@ -50,14 +50,14 @@ public void Translate()
"https://account.visualstudio.com/project/team/_git/repo",
"https://test.visualstudio.com/project/_git/repo",
"https://account.visualstudio.com/project/team/_git/repo",
- "https://account.visualstudio.com/_git/repo",
+ "https://account.visualstudio.com/project/_git/repo",
"https://contoso.com/account/project/team/_git/repo",
"https://contoso.com/account/project/team/_git/repo",
- "ssh://account@vs-ssh.visualstudio.com:22/project/team/_ssh/repo",
- "ssh://account@contoso.com:22/project/team/_ssh/repo",
- "ssh://account@vs-ssh.contoso.com:22/project/team/_ssh/repo",
- "ssh://account@vs-ssh.contoso2.com:22/project/team/_ssh/repo",
- "ssh://account@vs-ssh.contoso.com:22/project/team/ZZZ/repo"
+ "ssh://vs-ssh.visualstudio.com:22/v3/account/project/team/repo",
+ "ssh://contoso.com:22/v3/account/project/team/repo",
+ "ssh://vs-ssh.contoso.com:22/v3/account/project/team/repo",
+ "ssh://vs-ssh.contoso2.com:22/v3/account/project/team/repo",
+ "ssh://vs-ssh.contoso.com:22/v3/account/project/team/ZZZ/repo"
}, task.TranslatedSourceRoots?.Select(r => r.GetMetadata("ScmRepositoryUrl")));
Assert.True(result);
diff --git a/src/SourceLink.AzureRepos.Git/Microsoft.SourceLink.AzureRepos.Git.csproj b/src/SourceLink.AzureRepos.Git/Microsoft.SourceLink.AzureRepos.Git.csproj
index aee8ef8979..534b0564b0 100644
--- a/src/SourceLink.AzureRepos.Git/Microsoft.SourceLink.AzureRepos.Git.csproj
+++ b/src/SourceLink.AzureRepos.Git/Microsoft.SourceLink.AzureRepos.Git.csproj
@@ -16,6 +16,7 @@
+
diff --git a/src/SourceLink.AzureRepos.Git/Resources.resx b/src/SourceLink.AzureRepos.Git/Resources.resx
index aae4915e43..1c42f26e5d 100644
--- a/src/SourceLink.AzureRepos.Git/Resources.resx
+++ b/src/SourceLink.AzureRepos.Git/Resources.resx
@@ -129,4 +129,7 @@
The value passed to task parameter {0} is not a valid domain name: '{1}'
+
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+
\ No newline at end of file
diff --git a/src/SourceLink.AzureRepos.Git/TranslateRepositoryUrls.cs b/src/SourceLink.AzureRepos.Git/TranslateRepositoryUrls.cs
index 4dbdf60bad..01f6fccb71 100644
--- a/src/SourceLink.AzureRepos.Git/TranslateRepositoryUrls.cs
+++ b/src/SourceLink.AzureRepos.Git/TranslateRepositoryUrls.cs
@@ -10,9 +10,9 @@ namespace Microsoft.SourceLink.AzureRepos.Git
public sealed class TranslateRepositoryUrls : TranslateRepositoryUrlsGitTask
{
// Translates
- // ssh://{account}@{ssh-subdomain}.{domain}:{port}/{repositoryPath}/_ssh/{"_full"|"_optimized"}/{repositoryName}
+ // ssh://{user}@{domain}:{port}/v3/{account}/{repositoryPath}/{'_full'|'_optimized'|''}/{repositoryName}
// to
- // https://{http-domain}/{account}/{repositoryPath}/_git/{repositoryName}
+ // https://.../{repositoryPath}/_git/{repositoryName}
//
// Dommain mapping:
// ssh://vs-ssh.*.com -> https://{account}.*.com
@@ -27,8 +27,13 @@ public sealed class TranslateRepositoryUrls : TranslateRepositoryUrlsGitTask
return null;
}
- if (!AzureDevOpsUrlParser.TryParseHostedSsh(uri, out var account, out var repositoryPath, out var repositoryName))
+ if (!AzureDevOpsUrlParser.TryParseHostedSsh(uri, out var account, out var repositoryPath, out var repositoryName, out var isUnsupportedFormat))
{
+ if (isUnsupportedFormat)
+ {
+ throw new NotSupportedException(string.Format(Resources.RemoteUrlFormatNoLongerSupported, uri.AbsoluteUri));
+ }
+
return null;
}
diff --git a/src/SourceLink.AzureRepos.Git/xlf/Resources.cs.xlf b/src/SourceLink.AzureRepos.Git/xlf/Resources.cs.xlf
index aef5fb2ead..aba88fb561 100644
--- a/src/SourceLink.AzureRepos.Git/xlf/Resources.cs.xlf
+++ b/src/SourceLink.AzureRepos.Git/xlf/Resources.cs.xlf
@@ -7,6 +7,11 @@
Hodnota proměnné prostředí {0} není správně formátovaný seznam párů adres URL: {1}"
+
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+
+ The value of {0} with identity '{1}' is invalid: '{2}'"Hodnota {0} s identitou {1} je neplatná: {2}"
diff --git a/src/SourceLink.AzureRepos.Git/xlf/Resources.de.xlf b/src/SourceLink.AzureRepos.Git/xlf/Resources.de.xlf
index f9d6cd8ca1..2165061490 100644
--- a/src/SourceLink.AzureRepos.Git/xlf/Resources.de.xlf
+++ b/src/SourceLink.AzureRepos.Git/xlf/Resources.de.xlf
@@ -7,6 +7,11 @@
Der Wert der Umgebungsvariablen "{0}" ist keine wohlgeformte Liste mit URL-Paaren: "{1}"
+
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+
+ The value of {0} with identity '{1}' is invalid: '{2}'"Der Wert von "{0}" mit der Identität "{1}" ist ungültig: "{2}"
diff --git a/src/SourceLink.AzureRepos.Git/xlf/Resources.es.xlf b/src/SourceLink.AzureRepos.Git/xlf/Resources.es.xlf
index cf3efa11f2..4d672c6b74 100644
--- a/src/SourceLink.AzureRepos.Git/xlf/Resources.es.xlf
+++ b/src/SourceLink.AzureRepos.Git/xlf/Resources.es.xlf
@@ -7,6 +7,11 @@
El valor de la variable de entorno {0} no es una lista bien formada de pares de direcciones URL: '{1}'"
+
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+
+ The value of {0} with identity '{1}' is invalid: '{2}'"El valor de {0} con identidad '{1}' no es válido '{2}'"
diff --git a/src/SourceLink.AzureRepos.Git/xlf/Resources.fr.xlf b/src/SourceLink.AzureRepos.Git/xlf/Resources.fr.xlf
index 3cfb005474..681e6ee0ef 100644
--- a/src/SourceLink.AzureRepos.Git/xlf/Resources.fr.xlf
+++ b/src/SourceLink.AzureRepos.Git/xlf/Resources.fr.xlf
@@ -7,6 +7,11 @@
La valeur de la variable d'environnement {0} n'est pas une liste correctement formée de paires d'URL : '{1}'"
+
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+
+ The value of {0} with identity '{1}' is invalid: '{2}'"La valeur de {0} avec l'identité '{1}' n'est pas valide : '{2}'"
diff --git a/src/SourceLink.AzureRepos.Git/xlf/Resources.it.xlf b/src/SourceLink.AzureRepos.Git/xlf/Resources.it.xlf
index d63957052b..899c383c81 100644
--- a/src/SourceLink.AzureRepos.Git/xlf/Resources.it.xlf
+++ b/src/SourceLink.AzureRepos.Git/xlf/Resources.it.xlf
@@ -7,6 +7,11 @@
Il valore della variabile di ambiente {0} non è un elenco ben formato di coppie di URL: '{1}'"
+
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+
+ The value of {0} with identity '{1}' is invalid: '{2}'"Il valore di {0} con identità '{1}' non è valido: '{2}'"
diff --git a/src/SourceLink.AzureRepos.Git/xlf/Resources.ja.xlf b/src/SourceLink.AzureRepos.Git/xlf/Resources.ja.xlf
index a46569e801..ec7c89992d 100644
--- a/src/SourceLink.AzureRepos.Git/xlf/Resources.ja.xlf
+++ b/src/SourceLink.AzureRepos.Git/xlf/Resources.ja.xlf
@@ -7,6 +7,11 @@
環境変数 {0} の値は正しい形式の URL のペアの一覧ではありません: '{1}'"
+
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+
+ The value of {0} with identity '{1}' is invalid: '{2}'"ID '{1}' の {0} の値は無効です: '{2}'"
diff --git a/src/SourceLink.AzureRepos.Git/xlf/Resources.ko.xlf b/src/SourceLink.AzureRepos.Git/xlf/Resources.ko.xlf
index 8e025896ad..0b3184aff1 100644
--- a/src/SourceLink.AzureRepos.Git/xlf/Resources.ko.xlf
+++ b/src/SourceLink.AzureRepos.Git/xlf/Resources.ko.xlf
@@ -7,6 +7,11 @@
환경 변수 {0}의 값이 잘 구성된(Well-Formed) URL 쌍 목록이 아닙니다. '{1}'"
+
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+
+ The value of {0} with identity '{1}' is invalid: '{2}'"ID가 '{1}'인 {0}의 값이 잘못되었습니다. '{2}'"
diff --git a/src/SourceLink.AzureRepos.Git/xlf/Resources.pl.xlf b/src/SourceLink.AzureRepos.Git/xlf/Resources.pl.xlf
index 16889347ff..a8d98b0fde 100644
--- a/src/SourceLink.AzureRepos.Git/xlf/Resources.pl.xlf
+++ b/src/SourceLink.AzureRepos.Git/xlf/Resources.pl.xlf
@@ -7,6 +7,11 @@
Wartość zmiennej środowiskowej {0} nie jest prawidłowo sformułowaną listą par adresów URL: „{1}”"
+
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+
+ The value of {0} with identity '{1}' is invalid: '{2}'"Wartość elementu {0} z tożsamością „{1}” jest nieprawidłowa: „{2}”"
diff --git a/src/SourceLink.AzureRepos.Git/xlf/Resources.pt-BR.xlf b/src/SourceLink.AzureRepos.Git/xlf/Resources.pt-BR.xlf
index 38d4241ec7..78a60304fd 100644
--- a/src/SourceLink.AzureRepos.Git/xlf/Resources.pt-BR.xlf
+++ b/src/SourceLink.AzureRepos.Git/xlf/Resources.pt-BR.xlf
@@ -7,6 +7,11 @@
O valor da variável de ambiente {0} não é uma lista bem-formada de pares de URLs: '{1}'"
+
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+
+ The value of {0} with identity '{1}' is invalid: '{2}'"O valor de {0} com a identidade '{1}' é inválido: '{2}'"
diff --git a/src/SourceLink.AzureRepos.Git/xlf/Resources.ru.xlf b/src/SourceLink.AzureRepos.Git/xlf/Resources.ru.xlf
index 4010c44a3d..b85b11c707 100644
--- a/src/SourceLink.AzureRepos.Git/xlf/Resources.ru.xlf
+++ b/src/SourceLink.AzureRepos.Git/xlf/Resources.ru.xlf
@@ -7,6 +7,11 @@
Значение переменной среды {0} не является списком пар URL в корректном формате: "{1}"
+
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+
+ The value of {0} with identity '{1}' is invalid: '{2}'"Значение {0} с идентификатором "{1}" недопустимо: "{2}"
diff --git a/src/SourceLink.AzureRepos.Git/xlf/Resources.tr.xlf b/src/SourceLink.AzureRepos.Git/xlf/Resources.tr.xlf
index bab3b68924..a4b6ff2d24 100644
--- a/src/SourceLink.AzureRepos.Git/xlf/Resources.tr.xlf
+++ b/src/SourceLink.AzureRepos.Git/xlf/Resources.tr.xlf
@@ -7,6 +7,11 @@
{0} ortam değişkeninin değeri doğru biçimlendirilmiş bir URL çiftleri listesi değil: '{1}'"
+
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+
+ The value of {0} with identity '{1}' is invalid: '{2}'"'{1}' kimliğine sahip {0} değeri geçersiz: '{2}'"
diff --git a/src/SourceLink.AzureRepos.Git/xlf/Resources.zh-Hans.xlf b/src/SourceLink.AzureRepos.Git/xlf/Resources.zh-Hans.xlf
index bc963ae656..2180e87150 100644
--- a/src/SourceLink.AzureRepos.Git/xlf/Resources.zh-Hans.xlf
+++ b/src/SourceLink.AzureRepos.Git/xlf/Resources.zh-Hans.xlf
@@ -7,6 +7,11 @@
环境变量 {0} 的值不是形式正确的 URL 对列表: '{1}'"
+
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+
+ The value of {0} with identity '{1}' is invalid: '{2}'"带有 '{1}' 标识的 {0} 的值无效: '{2}'"
diff --git a/src/SourceLink.AzureRepos.Git/xlf/Resources.zh-Hant.xlf b/src/SourceLink.AzureRepos.Git/xlf/Resources.zh-Hant.xlf
index 177fe5765a..fdcd3c45d2 100644
--- a/src/SourceLink.AzureRepos.Git/xlf/Resources.zh-Hant.xlf
+++ b/src/SourceLink.AzureRepos.Git/xlf/Resources.zh-Hant.xlf
@@ -7,6 +7,11 @@
環境變數 {0} 的值不是正確格式的 URL 配對清單: '{1}'"
+
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+ Remote URL '{0}' appears to be in a format that is no longer supported.
+
+ The value of {0} with identity '{1}' is invalid: '{2}'"識別為 {1} 之 {0} 的值無效: '{2}'"
diff --git a/src/SourceLink.Git.IntegrationTests/AzureReposTests.cs b/src/SourceLink.Git.IntegrationTests/AzureReposTests.cs
index 4755f68001..812d0701bf 100644
--- a/src/SourceLink.Git.IntegrationTests/AzureReposTests.cs
+++ b/src/SourceLink.Git.IntegrationTests/AzureReposTests.cs
@@ -3,6 +3,7 @@
// See the License.txt file in the project root for more information.
using System.IO;
+using Microsoft.SourceLink.AzureRepos.Git;
using TestUtilities;
using Xunit;
@@ -79,58 +80,28 @@ public void FullValidation_Https(string host)
[InlineData("vsts.me")]
public void FullValidation_Ssh(string host)
{
- // Test non - ascii characters and escapes in the URL.
- // Escaped URI reserved characters should remain escaped, non-reserved characters unescaped in the results.
- var repoUrl = $"ssh://test@vs-ssh.{host}:22/test-org/_ssh/test-%72epo{TestStrings.RepoName}";
- var repoName = $"test-repo{TestStrings.RepoNameEscaped}";
+ var repoUrl = $"ssh://user@vs-ssh.{host}:22/test-org/_ssh/test-repo";
var repo = GitUtilities.CreateGitRepository(ProjectDir.Path, new[] { ProjectFileName }, repoUrl);
var commitSha = repo.Head.Tip.Sha;
VerifyValues(
customProps: @"
-
- true
-
",
customTargets: "",
targets: new[]
{
- "Build", "Pack"
+ "Build",
},
expressions: new[]
{
"@(SourceRoot)",
- "@(SourceRoot->'%(SourceLinkUrl)')",
- "@(SourceRoot->'%(BranchName)')",
- "$(SourceLink)",
- "$(PrivateRepositoryUrl)",
- "$(RepositoryUrl)"
},
- expectedResults: new[]
+ expectedErrors: new[]
{
- NuGetPackageFolders,
- ProjectSourceRoot,
- $"https://test.{host}/test-org/_apis/git/repositories/{repoName}/items?api-version=1.0&versionType=commit&version={commitSha}&path=/*",
- "refs/heads/main",
- s_relativeSourceLinkJsonPath,
- $"https://test.{host}/test-org/_git/{repoName}",
- $"https://test.{host}/test-org/_git/{repoName}",
+ string.Format(Resources.RemoteUrlFormatNoLongerSupported,
+ $"ssh://git@vs-ssh.{host}:22/test-org/_ssh/test-repo")
});
-
- AssertEx.AreEqual(
- $@"{{""documents"":{{""{ProjectSourceRoot.Replace(@"\", @"\\")}*"":""https://test.{host}/test-org/_apis/git/repositories/{repoName}/items?api-version=1.0&versionType=commit&version={commitSha}&path=/*""}}}}",
- File.ReadAllText(Path.Combine(ProjectDir.Path, s_relativeSourceLinkJsonPath)));
-
- TestUtilities.ValidateAssemblyInformationalVersion(
- Path.Combine(ProjectDir.Path, s_relativeOutputFilePath),
- "1.0.0+" + commitSha);
-
- TestUtilities.ValidateNuSpecRepository(
- Path.Combine(ProjectDir.Path, s_relativePackagePath),
- type: "git",
- commit: commitSha,
- url: $"https://test.{host}/test-org/_git/{repoName}");
}
}
}
diff --git a/src/SourceLink.Git.IntegrationTests/CloudHostedProvidersTests.cs b/src/SourceLink.Git.IntegrationTests/CloudHostedProvidersTests.cs
index 56cac30370..94aaab06de 100644
--- a/src/SourceLink.Git.IntegrationTests/CloudHostedProvidersTests.cs
+++ b/src/SourceLink.Git.IntegrationTests/CloudHostedProvidersTests.cs
@@ -242,7 +242,7 @@ public void CustomTranslation()
{
// Test non-ascii characters and escapes in the URL.
// Escaped URI reserved characters should remain escaped, non-reserved characters unescaped in the results.
- var repoUrl = $"ssh://test@vs-ssh.visualstudio.com:22/test-org/_ssh/test-%72epo{TestStrings.RepoName}";
+ var repoUrl = $"ssh://test@vs-ssh.visualstudio.com/v3/account/test-org/test-%72epo{TestStrings.RepoName}";
var repoName = $"test-repo{TestStrings.RepoNameEscaped}";
var repo = GitUtilities.CreateGitRepository(ProjectDir.Path, new[] { ProjectFileName }, repoUrl);
diff --git a/src/SourceLink.Git.IntegrationTests/GitWebTests.cs b/src/SourceLink.Git.IntegrationTests/GitWebTests.cs
index 2a5a8aa7ff..3e71cd76a7 100644
--- a/src/SourceLink.Git.IntegrationTests/GitWebTests.cs
+++ b/src/SourceLink.Git.IntegrationTests/GitWebTests.cs
@@ -21,7 +21,7 @@ public void FullValidation_Ssh()
{
// Test non-ascii characters and escapes in the URL. Escaped URI reserved characters
// should remain escaped, non-reserved characters unescaped in the results.
- var repoUrl = $"ssh://git@{TestStrings.DomainName}.com/test-%72epo{TestStrings.RepoName}.git";
+ var repoUrl = $"ssh://user@{TestStrings.DomainName}.com/test-%72epo{TestStrings.RepoName}.git";
var repoName = $"test-repo{TestStrings.RepoNameEscaped}.git";
var repoNameFullyEscaped = $"test-repo{TestStrings.RepoNameFullyEscaped}.git";
@@ -58,6 +58,7 @@ public void FullValidation_Ssh()
$"https://{TestStrings.DomainName}.com/gitweb/?p={repoName};a=blob_plain;hb={commitSha};f=*",
"refs/heads/main",
s_relativeSourceLinkJsonPath,
+ // note that "user" was replaced with "git" to avoid leaking user info
$"ssh://git@{TestStrings.DomainName}.com/{repoNameFullyEscaped}",
$"ssh://git@{TestStrings.DomainName}.com/{repoNameFullyEscaped}"
});
@@ -70,6 +71,7 @@ public void FullValidation_Ssh()
Path.Combine(ProjectDir.Path, s_relativeOutputFilePath),
"1.0.0+" + commitSha);
+ // note that "user" was replaced with "git" to avoid leaking user info
TestUtilities.ValidateNuSpecRepository(
Path.Combine(ProjectDir.Path, s_relativePackagePath),
type: "git",
diff --git a/src/SourceLink.Git.IntegrationTests/Microsoft.SourceLink.Git.IntegrationTests.csproj b/src/SourceLink.Git.IntegrationTests/Microsoft.SourceLink.Git.IntegrationTests.csproj
index 8a4acbdb24..66045a53d4 100644
--- a/src/SourceLink.Git.IntegrationTests/Microsoft.SourceLink.Git.IntegrationTests.csproj
+++ b/src/SourceLink.Git.IntegrationTests/Microsoft.SourceLink.Git.IntegrationTests.csproj
@@ -5,6 +5,7 @@
+
diff --git a/src/TestUtilities/DotNetSdk/DotNetSdkTestBase.cs b/src/TestUtilities/DotNetSdk/DotNetSdkTestBase.cs
index 5c23fbe850..5daf9da68c 100644
--- a/src/TestUtilities/DotNetSdk/DotNetSdkTestBase.cs
+++ b/src/TestUtilities/DotNetSdk/DotNetSdkTestBase.cs
@@ -52,7 +52,7 @@ private static string GetLocalNuGetConfigContent(string packagesDir) =>
-
+