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
87 changes: 87 additions & 0 deletions azure-pipelines/Get-GitHubAppToken.ps1
Original file line number Diff line number Diff line change
@@ -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)"
17 changes: 15 additions & 2 deletions azure-pipelines/loc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
65 changes: 46 additions & 19 deletions tasks/localization/publishLocalizationContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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;
Expand All @@ -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';
Expand All @@ -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}`;
Expand Down Expand Up @@ -139,23 +149,40 @@ async function git_diff(args: string[]): Promise<string[]> {
.filter((fileName) => fileName.length !== 0);
}

async function git(args: string[], printCommand = true): Promise<string> {
async function git(args: string[], options: GitOptions = {}): Promise<string> {
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}.`);
Comment on lines +159 to +174

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we sanitize the output for known PAT formats?

}

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);
}