diff --git a/azure-pipelines/Get-GitHubAppToken.ps1 b/azure-pipelines/Get-GitHubAppToken.ps1 new file mode 100644 index 000000000..f55135f43 --- /dev/null +++ b/azure-pipelines/Get-GitHubAppToken.ps1 @@ -0,0 +1,87 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string] $KeyVaultName, + + [Parameter(Mandatory = $true)] + [string] $KeyName, + + [Parameter(Mandatory = $true)] + [string] $AppClientId, + + [Parameter(Mandatory = $true)] + [string] $InstallationOwner, + + [Parameter(Mandatory = $true)] + [string] $OutputVariableName +) + +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true + +function ConvertTo-Base64Url([byte[]] $bytes) { + return [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') +} + +$jwtHeader = [ordered]@{ + alg = 'RS256' + typ = 'JWT' +} +$now = [System.DateTimeOffset]::UtcNow +$jwtPayload = [ordered]@{ + iat = $now.AddMinutes(-1).ToUnixTimeSeconds() + exp = $now.AddMinutes(5).ToUnixTimeSeconds() + iss = $AppClientId +} + +$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() +$digestBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($signingInput)) +$digestBase64 = [Convert]::ToBase64String($digestBytes) + +Write-Host "Signing GitHub App JWT with key '$KeyName' in vault '$KeyVaultName'..." +$signResponseJson = az keyvault key sign ` + --vault-name $KeyVaultName ` + --name $KeyName ` + --algorithm RS256 ` + --digest $digestBase64 ` + --output json +if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($signResponseJson)) { + throw "'az keyvault key sign' failed with exit code $LASTEXITCODE for key '$KeyName' in vault '$KeyVaultName'." +} + +$signResponse = $signResponseJson | ConvertFrom-Json +if ([string]::IsNullOrWhiteSpace($signResponse.signature)) { + throw "Key Vault returned an empty signature for key '$KeyName' in vault '$KeyVaultName'." +} + +$signatureEncoded = $signResponse.signature.TrimEnd('=').Replace('+', '-').Replace('/', '_') +$jwt = "$signingInput.$signatureEncoded" +$headers = @{ + Authorization = "Bearer $jwt" + 'X-GitHub-Api-Version' = '2022-11-28' + Accept = 'application/vnd.github+json' + 'User-Agent' = 'vscode-csharp-onelocbuild' +} + +Write-Host "Looking up the GitHub App installation for '$InstallationOwner'..." +$installations = Invoke-RestMethod -Uri 'https://api.github.com/app/installations' -Headers $headers -Method Get +$installation = $installations | Where-Object { $_.account.login -eq $InstallationOwner } | Select-Object -First 1 +if (-not $installation) { + throw "No GitHub App installation found for '$InstallationOwner'." +} + +$tokenResponse = Invoke-RestMethod ` + -Uri "https://api.github.com/app/installations/$($installation.id)/access_tokens" ` + -Headers $headers ` + -Method Post ` + -ContentType 'application/json' +if ([string]::IsNullOrWhiteSpace($tokenResponse.token)) { + throw "GitHub returned an empty installation token for '$InstallationOwner'." +} + +Write-Host "Got an installation token for '$InstallationOwner' that expires at $($tokenResponse.expires_at)." +Write-Host "##vso[task.setvariable variable=$OutputVariableName;issecret=true]$($tokenResponse.token)" diff --git a/azure-pipelines/loc.yml b/azure-pipelines/loc.yml index 59eb12003..0c84bf9da 100644 --- a/azure-pipelines/loc.yml +++ b/azure-pipelines/loc.yml @@ -28,7 +28,7 @@ resources: ref: refs/tags/release variables: -# Variable group contains the PAT to LOC +# Variable group contains the credential for the localization package source. - group: OneLocBuildVariables extends: @@ -86,7 +86,20 @@ extends: LclPackageId: 'LCL-JUNO-PROD-VSCODECS' - pwsh: npm run l10nDevImportXlf displayName: 'Import xlf to json.' + - task: AzureCLI@2 + displayName: 'Get GitHub App installation token' + inputs: + azureSubscription: 'dnceng-oneloc-githubapp' + scriptType: pscore + scriptLocation: inlineScript + inlineScript: | + & "$(Build.SourcesDirectory)/azure-pipelines/Get-GitHubAppToken.ps1" ` + -KeyVaultName 'EngKeyVault' ` + -KeyName 'oneloc-localization-app-key' ` + -AppClientId 'Iv23lijBU8x3gc9lDOc9' ` + -InstallationOwner 'dotnet' ` + -OutputVariableName 'GitHubAppInstallationToken' - pwsh: npm run publishLocalizationContent -- --userName dotnet-bot --email dotnet-bot@dotnetfoundation.org --commitSha $(Build.SourceVersion) --targetRemoteRepo vscode-csharp --baseBranch 'main' displayName: 'Create PR in GitHub.' env: - GitHubPAT: $(BotAccount-dotnet-bot-repo-PAT) + GitHubToken: $(GitHubAppInstallationToken) diff --git a/package.nls.json b/package.nls.json index 7e3846ee0..6971ab685 100644 --- a/package.nls.json +++ b/package.nls.json @@ -124,7 +124,7 @@ "configuration.omnisharp.projectFilesExcludePattern": "The exclude pattern used by OmniSharp to find all project files.", "configuration.omnisharp.projectLoadTimeout": "The time Visual Studio Code will wait for the OmniSharp server to start. Time is expressed in seconds.", "configuration.omnisharp.maxProjectResults": "The maximum number of projects to be shown in the 'Select Project' dropdown (maximum 250).", - "configuration.omnisharp.useEditorFormattingSettings": "Specifes whether OmniSharp should use VS Code editor settings for C# code formatting (use of tabs, indentation size).", + "configuration.omnisharp.useEditorFormattingSettings": "Specifies whether OmniSharp should use VS Code editor settings for C# code formatting (use of tabs, indentation size).", "configuration.omnisharp.minFindSymbolsFilterLength": "The minimum number of characters to enter before 'Go to Symbol in Workspace' operation shows any results.", "configuration.omnisharp.maxFindSymbolsItems": "The maximum number of items that 'Go to Symbol in Workspace' operation can show. The limit is applied only when a positive number is specified here.", "configuration.omnisharp.disableMSBuildDiagnosticWarning": "Specifies whether notifications should be shown if OmniSharp encounters warnings or errors loading a project. Note that these warnings/errors are always emitted to the OmniSharp log", diff --git a/tasks/localization/publishLocalizationContent.ts b/tasks/localization/publishLocalizationContent.ts index 2aaf3a2db..06c8fab97 100644 --- a/tasks/localization/publishLocalizationContent.ts +++ b/tasks/localization/publishLocalizationContent.ts @@ -13,6 +13,11 @@ import { runTask } from '../runTask'; const localizationLanguages = ['cs', 'de', 'es', 'fr', 'it', 'ja', 'ko', 'pl', 'pt-br', 'ru', 'tr', 'zh-cn', 'zh-tw']; +type GitOptions = { + printCommand?: boolean; + sensitiveValues?: readonly string[]; +}; + runTask(publishLocalizationContent); async function publishLocalizationContent() { @@ -35,7 +40,8 @@ async function publishLocalizationContent() { const localizationChanges = getAllPossibleLocalizationFiles(); await git(['add'].concat(localizationChanges)); - const diff = await git_diff(['--name-only', 'HEAD']); + // Check only staged localization files; other build steps may leave unrelated tracked files modified. + const diff = await git_diff(['--cached', '--name-only']); if (diff.length == 0) { console.log('No localization file changed'); return; @@ -55,9 +61,9 @@ async function publishLocalizationContent() { await git(['checkout', '-b', newBranchName]); await git(['commit', '-m', `Localization result of ${parsedArgs.commitSha}.`]); - const pat = process.env['GitHubPAT']; - if (!pat) { - throw 'No GitHub Pat found.'; + const token = process.env['GitHubToken']; + if (!token) { + throw new Error('No GitHub token found.'); } const remoteRepoAlias = 'targetRepo'; @@ -66,31 +72,35 @@ async function publishLocalizationContent() { 'remote', 'add', remoteRepoAlias, - `https://${parsedArgs.userName}:${pat}@github.com/dotnet/${parsedArgs.targetRemoteRepo}.git`, + `https://x-access-token:${token}@github.com/dotnet/${parsedArgs.targetRemoteRepo}.git`, ], - // Note: don't print PAT to console - false + { + printCommand: false, + sensitiveValues: [token], + } ); - await git(['fetch', remoteRepoAlias]); + await git(['fetch', remoteRepoAlias], { sensitiveValues: [token] }); - const lsRemote = await git(['ls-remote', remoteRepoAlias, 'refs/head/' + newBranchName]); + const lsRemote = await git(['ls-remote', remoteRepoAlias, 'refs/head/' + newBranchName], { + sensitiveValues: [token], + }); if (lsRemote.trim() !== '') { // If the localization branch of this commit already exists, don't try to create another one. console.log( `##vso[task.logissue type=error]${newBranchName} already exists in ${parsedArgs.targetRemoteRepo}. Skip pushing.` ); } else { - await git(['push', '-u', remoteRepoAlias]); + await git(['push', '-u', remoteRepoAlias], { sensitiveValues: [token] }); } - const octokit = new Octokit({ auth: pat }); + const octokit = new Octokit({ auth: token }); const listPullRequest = await octokit.rest.pulls.list({ owner: 'dotnet', repo: parsedArgs.targetRemoteRepo, }); if (listPullRequest.status != 200) { - throw `Failed get response from GitHub, http status code: ${listPullRequest.status}`; + throw new Error(`Failed get response from GitHub, http status code: ${listPullRequest.status}`); } const title = `Localization result based on ${parsedArgs.commitSha}`; @@ -139,23 +149,40 @@ async function git_diff(args: string[]): Promise { .filter((fileName) => fileName.length !== 0); } -async function git(args: string[], printCommand = true): Promise { +async function git(args: string[], options: GitOptions = {}): Promise { + const { printCommand = true, sensitiveValues = [] } = options; if (printCommand) { console.log(`git ${args.join(' ')}`); } - const git = spawnSync('git', args); - if (git.status != 0) { - const err = git.stderr.toString(); + const result = spawnSync('git', args, { encoding: 'utf8' }); + const command = printCommand ? `git ${args.join(' ')}` : 'git command'; + if (result.error) { + throw new Error(`Failed to start ${command}: ${redact(result.error.message, sensitiveValues)}`); + } + if (result.status != 0) { + const output = redact( + [result.stderr, result.stdout] + .map((value) => value.trim()) + .filter((value) => value.length > 0) + .join(EOL), + sensitiveValues + ); if (printCommand) { - console.log(`Failed to execute git ${args.join(' ')}.`); + console.error(`Failed to execute ${command}.`); } - throw err; + throw new Error(output || `${command} failed with code ${result.status}.`); } - const stdout = git.stdout.toString(); + const stdout = result.stdout; if (printCommand) { console.log(stdout); } return stdout; } + +function redact(value: string, sensitiveValues: readonly string[]): string { + return sensitiveValues + .filter((sensitiveValue) => sensitiveValue.length > 0) + .reduce((redactedValue, sensitiveValue) => redactedValue.replaceAll(sensitiveValue, '***'), value); +}