Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion eng/Version.Details.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This file should be imported by eng/Versions.props
<Project>
<PropertyGroup>
<!-- dotnet-arcade dependencies -->
<MicrosoftDotNetArcadeSdkPackageVersion>11.0.0-beta.26279.5</MicrosoftDotNetArcadeSdkPackageVersion>
<MicrosoftDotNetArcadeSdkPackageVersion>11.0.0-beta.26461.5</MicrosoftDotNetArcadeSdkPackageVersion>
</PropertyGroup>
<!--Property group for alternate package version names-->
<PropertyGroup>
Expand Down
4 changes: 2 additions & 2 deletions eng/Version.Details.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
<ProductDependencies>
</ProductDependencies>
<ToolsetDependencies>
<Dependency Name="Microsoft.DotNet.Arcade.Sdk" Version="11.0.0-beta.26279.5">
<Dependency Name="Microsoft.DotNet.Arcade.Sdk" Version="11.0.0-beta.26461.5">
<Uri>https://github.com/dotnet/arcade</Uri>
<Sha>2bc908b1bdb34e2c42982eb511679bb458ddac2c</Sha>
<Sha>1574a0ce35761b7ce5e783074cc2f9567d278396</Sha>
</Dependency>
</ToolsetDependencies>
</Dependencies>
208 changes: 208 additions & 0 deletions eng/common/Get-GitHubAppToken.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# Mints a short-lived GitHub App installation access token by signing a JWT
# 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 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.
#
# Installation tokens (ghs_*) are exempt from the enterprise classic-PAT
# lifetime policy, which is why this replaces the long-lived PAT.

[CmdletBinding()]
param(
# Name of the Key Vault holding the GitHub App credentials.
[Parameter(Mandatory = $true)]
[string] $KeyVaultName,

# Secret Manager projection containing the GitHub App ID.
[Parameter(Mandatory = $true)]
[string] $AppIdSecretName,

# Secret Manager projection containing the PEM private key.
[Parameter(Mandatory = $true)]
[string] $AppPrivateKeySecretName,

# Login of the organization or user account whose installation we should
# mint the token for (e.g. `dotnet`, `microsoft`).
[Parameter(Mandatory = $true)]
[string] $InstallationOwner,

# Optional Azure DevOps pipeline variable name to set with the installation
# token (marked as a secret). When not specified, the token is written to
# stdout instead.
[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]@{
alg = 'RS256'
typ = 'JWT'
}
$now = [System.DateTimeOffset]::UtcNow
$jwtPayload = [ordered]@{
iat = $now.AddMinutes(-1).ToUnixTimeSeconds()
exp = $now.AddMinutes(5).ToUnixTimeSeconds()
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"

$sha256 = [System.Security.Cryptography.SHA256]::Create()
try {
$digestBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($signingInput))
}
finally {
$sha256.Dispose()
}

Write-Host 'Signing JWT with the GitHub App private key...'
$rsa = [System.Security.Cryptography.RSA]::Create()
try {
$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 GitHub App JWT with the supplied private key: $_"
exit 1
}
finally {
$rsa.Dispose()
}
$jwt = "$signingInput.$signatureUrl"

$headers = @{
Authorization = "Bearer $jwt"
'X-GitHub-Api-Version' = '2022-11-28'
Accept = 'application/vnd.github+json'
'User-Agent' = 'dotnet-arcade-onelocbuild'
}

Write-Host "Looking up installation for '$InstallationOwner'..."
try {
$installations = [System.Collections.Generic.List[object]]::new()
$page = 1
do {
$pageResponse = Invoke-RestMethod `
-Uri "https://api.github.com/app/installations?per_page=100&page=$page" `
-Headers $headers `
-Method Get
$pageInstallationCount = 0
foreach ($installation in $pageResponse) {
$installations.Add($installation)
$pageInstallationCount++
}
$page++
} 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 ID may be incorrect."
exit 1
}
$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 `
-Uri "https://api.github.com/app/installations/$($installation.id)/access_tokens" `
-Headers $headers `
-Method Post `
-ContentType 'application/json'
}
catch {
Write-PipelineTelemetryError -Category 'Build' -Message "Failed to mint an installation access token for '$InstallationOwner' (installation $($installation.id)): $_"
exit 1
}

Write-Host "Got installation token for '$InstallationOwner' (expires $($tokenResponse.expires_at))."
if ($OutputVariableName) {
Write-Host "Setting pipeline variable '$OutputVariableName'."
Write-Host "##vso[task.setvariable variable=$OutputVariableName;issecret=true]$($tokenResponse.token)"
}
else {
Write-Host $tokenResponse.token -ForegroundColor Green
}
51 changes: 31 additions & 20 deletions eng/common/SetupNugetSources.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@
# condition: eq(variables['Agent.OS'], 'Windows_NT')
# inputs:
# filePath: $(System.DefaultWorkingDirectory)/eng/common/SetupNugetSources.ps1
# arguments: -ConfigFile $(System.DefaultWorkingDirectory)/NuGet.config -Password $Env:Token
# arguments: -ConfigFile $(System.DefaultWorkingDirectory)/NuGet.config
# env:
# Token: $(dn-bot-dnceng-artifact-feeds-rw)
# Token: $(InternalFeedToken)
#
# Note: This logic is abstracted into enable-internal-sources.yml, which uses
# NuGetAuthenticate or a WIF-backed service connection. Prefer that template
# over calling this script directly.
#
# Note that the NuGetAuthenticate task should be called after SetupNugetSources.
# This ensures that:
Expand All @@ -25,24 +29,31 @@
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)][string]$ConfigFile,
$Password
# Keep the legacy name as an alias while callers migrate secrets to the Token environment variable.
[Alias("Password")]$Credential
)

$ErrorActionPreference = "Stop"
Set-StrictMode -Version 2.0
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$feedCredential = if ($env:Token) { $env:Token } else { $Credential }

# This script only consumes helper functions from tools.ps1 to configure NuGet feeds.
# Skip importing configure-toolset.ps1 so that repo-specific toolset setup (e.g. acquiring
# a bootstrap SDK) is not triggered as a side effect of feed configuration.
$disableConfigureToolsetImport = $true

. $PSScriptRoot\tools.ps1

# Adds or enables the package source with the given name
function AddOrEnablePackageSource($sources, $disabledPackageSources, $SourceName, $SourceEndPoint, $creds, $Username, $pwd) {
if ($disabledPackageSources -eq $null -or -not (EnableInternalPackageSource -DisabledPackageSources $disabledPackageSources -Creds $creds -PackageSourceName $SourceName)) {
AddPackageSource -Sources $sources -SourceName $SourceName -SourceEndPoint $SourceEndPoint -Creds $creds -Username $userName -pwd $Password
function AddOrEnablePackageSource($sources, $disabledPackageSources, $SourceName, $SourceEndPoint, $creds, $Username, $credential) {
if ($disabledPackageSources -eq $null -or -not (EnableInternalPackageSource -DisabledPackageSources $disabledPackageSources -Creds $creds -PackageSourceName $SourceName -Credential $credential)) {
AddPackageSource -Sources $sources -SourceName $SourceName -SourceEndPoint $SourceEndPoint -Creds $creds -Username $Username -credential $credential
}
}

# Add source entry to PackageSources
function AddPackageSource($sources, $SourceName, $SourceEndPoint, $creds, $Username, $pwd) {
function AddPackageSource($sources, $SourceName, $SourceEndPoint, $creds, $Username, $credential) {
$packageSource = $sources.SelectSingleNode("add[@key='$SourceName']")

if ($packageSource -eq $null)
Expand All @@ -58,13 +69,13 @@ function AddPackageSource($sources, $SourceName, $SourceEndPoint, $creds, $Usern
Write-Host "Package source $SourceName already present and enabled."
}

AddCredential -Creds $creds -Source $SourceName -Username $Username -pwd $pwd
AddCredential -Creds $creds -Source $SourceName -Username $Username -credential $credential
}

# Add a credential node for the specified source
function AddCredential($creds, $source, $username, $pwd) {
function AddCredential($creds, $source, $username, $credential) {
# If no cred supplied, don't do anything.
if (!$pwd) {
if (!$credential) {
return;
}

Expand Down Expand Up @@ -99,27 +110,27 @@ function AddCredential($creds, $source, $username, $pwd) {
$sourceElement.AppendChild($passwordElement) | Out-Null
}

$passwordElement.SetAttribute("value", $pwd)
$passwordElement.SetAttribute("value", $credential)
}

# Enable all darc-int package sources.
function EnableMaestroInternalPackageSources($DisabledPackageSources, $Creds) {
function EnableMaestroInternalPackageSources($DisabledPackageSources, $Creds, $Credential) {
$maestroInternalSources = $DisabledPackageSources.SelectNodes("add[contains(@key,'darc-int')]")
ForEach ($DisabledPackageSource in $maestroInternalSources) {
EnableInternalPackageSource -DisabledPackageSources $DisabledPackageSources -Creds $Creds -PackageSourceName $DisabledPackageSource.key
EnableInternalPackageSource -DisabledPackageSources $DisabledPackageSources -Creds $Creds -PackageSourceName $DisabledPackageSource.key -Credential $Credential
}
}

# Enables an internal package source by name, if found. Returns true if the package source was found and enabled, false otherwise.
function EnableInternalPackageSource($DisabledPackageSources, $Creds, $PackageSourceName) {
function EnableInternalPackageSource($DisabledPackageSources, $Creds, $PackageSourceName, $Credential) {
$DisabledPackageSource = $DisabledPackageSources.SelectSingleNode("add[@key='$PackageSourceName']")
if ($DisabledPackageSource) {
Write-Host "Enabling internal source '$($DisabledPackageSource.key)'."

# Due to https://github.com/NuGet/Home/issues/10291, we must actually remove the disabled entries
$DisabledPackageSources.RemoveChild($DisabledPackageSource)

AddCredential -Creds $creds -Source $DisabledPackageSource.Key -Username $userName -pwd $Password
AddCredential -Creds $creds -Source $DisabledPackageSource.Key -Username $userName -credential $credential
return $true
}
return $false
Expand All @@ -144,7 +155,7 @@ if ($sources -eq $null) {

$creds = $null
$feedSuffix = "v3/index.json"
if ($Password) {
if ($feedCredential) {
$feedSuffix = "v2"
# Looks for a <PackageSourceCredentials> node. Create it if none is found.
$creds = $doc.DocumentElement.SelectSingleNode("packageSourceCredentials")
Expand All @@ -160,16 +171,16 @@ $userName = "dn-bot"
$disabledSources = $doc.DocumentElement.SelectSingleNode("disabledPackageSources")
if ($disabledSources -ne $null) {
Write-Host "Checking for any darc-int disabled package sources in the disabledPackageSources node"
EnableMaestroInternalPackageSources -DisabledPackageSources $disabledSources -Creds $creds
EnableMaestroInternalPackageSources -DisabledPackageSources $disabledSources -Creds $creds -Credential $feedCredential
}
$dotnetVersions = @('5','6','7','8','9','10')
$dotnetVersions = @('5','6','7','8','9','10','11')

foreach ($dotnetVersion in $dotnetVersions) {
$feedPrefix = "dotnet" + $dotnetVersion;
$dotnetSource = $sources.SelectSingleNode("add[@key='$feedPrefix']")
if ($dotnetSource -ne $null) {
AddOrEnablePackageSource -Sources $sources -DisabledPackageSources $disabledSources -SourceName "$feedPrefix-internal" -SourceEndPoint "https://pkgs.dev.azure.com/dnceng/internal/_packaging/$feedPrefix-internal/nuget/$feedSuffix" -Creds $creds -Username $userName -pwd $Password
AddOrEnablePackageSource -Sources $sources -DisabledPackageSources $disabledSources -SourceName "$feedPrefix-internal-transport" -SourceEndPoint "https://pkgs.dev.azure.com/dnceng/internal/_packaging/$feedPrefix-internal-transport/nuget/$feedSuffix" -Creds $creds -Username $userName -pwd $Password
AddOrEnablePackageSource -Sources $sources -DisabledPackageSources $disabledSources -SourceName "$feedPrefix-internal" -SourceEndPoint "https://pkgs.dev.azure.com/dnceng/internal/_packaging/$feedPrefix-internal/nuget/$feedSuffix" -Creds $creds -Username $userName -credential $feedCredential
AddOrEnablePackageSource -Sources $sources -DisabledPackageSources $disabledSources -SourceName "$feedPrefix-internal-transport" -SourceEndPoint "https://pkgs.dev.azure.com/dnceng/internal/_packaging/$feedPrefix-internal-transport/nuget/$feedSuffix" -Creds $creds -Username $userName -credential $feedCredential
}
}

Expand Down
11 changes: 9 additions & 2 deletions eng/common/SetupNugetSources.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
# This logic is also abstracted into enable-internal-sources.yml.

ConfigFile=$1
CredToken=$2
# Prefer the environment variable so credentials do not appear in process arguments.
# Retain the positional argument as a compatibility fallback for existing callers.
CredToken=${Token:-$2}
NL='\n'
TB=' '

Expand All @@ -40,6 +42,11 @@ while [[ -h "$source" ]]; do
done
scriptroot="$( cd -P "$( dirname "$source" )" && pwd )"

# This script only consumes helper functions from tools.sh to configure NuGet feeds.
# Skip importing configure-toolset.sh so that repo-specific toolset setup (e.g. acquiring
# a bootstrap SDK) is not triggered as a side effect of feed configuration.
disable_configure_toolset_import=1

. "$scriptroot/tools.sh"

if [ ! -f "$ConfigFile" ]; then
Expand Down Expand Up @@ -162,7 +169,7 @@ if [ "$?" == "0" ]; then
EnableMaestroInternalPackageSources
fi

DotNetVersions=('5' '6' '7' '8' '9' '10')
DotNetVersions=('5' '6' '7' '8' '9' '10' '11')

for DotNetVersion in ${DotNetVersions[@]} ; do
FeedPrefix="dotnet${DotNetVersion}";
Expand Down
Loading
Loading