From 247ca93d074cfcc4732d3ce15fb3457d2a6760bf Mon Sep 17 00:00:00 2001 From: Microsoft Graph DevX Tooling Date: Tue, 14 Jul 2026 15:45:06 -0700 Subject: [PATCH] ci: use microsoft-graph-devx-bot GitHub App instead of PAT Pivots the weekly reference docs pipeline off the PAT-backed GITHUB_TOKEN secret and onto the existing microsoft-graph-devx-bot GitHub App identity for both the branch push and PR creation. - scripts/Generate-Github-Token.ps1 (new): mints a short-lived App installation token from the App ID + private key. - powershell-docs.yml: fetch App secrets from Key Vault and push using an App installation token (masked) instead of the PAT. - create-pr.yml: mint an App token for gh pr create; drop the redundant PAT-based git push. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common-templates/create-pr.yml | 9 +- azure-pipelines/powershell-docs.yml | 16 +- scripts/Generate-Github-Token.ps1 | 201 ++++++++++++++++++ 3 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 scripts/Generate-Github-Token.ps1 diff --git a/azure-pipelines/common-templates/create-pr.yml b/azure-pipelines/common-templates/create-pr.yml index 65173b1cf8216..ed1a64e51f499 100644 --- a/azure-pipelines/common-templates/create-pr.yml +++ b/azure-pipelines/common-templates/create-pr.yml @@ -19,7 +19,8 @@ steps: - task: PowerShell@2 displayName: Create Pull Request for generated build env: - GITHUB_TOKEN: $(GITHUB_TOKEN) + GhAppId: $(microsoft-graph-devx-bot-appid) + GhAppKey: $(microsoft-graph-devx-bot-privatekey) inputs: pwsh: true targetType: inline @@ -28,6 +29,10 @@ steps: $Head = "MicrosoftDocs:${{ parameters.TargetBranch }}" $Title = "${{ parameters.Title }}" $Body = "${{ parameters.Body }}" + # The microsoft-graph-devx-bot GitHub App must have pull_requests:write on this repo. + $env:GITHUB_TOKEN = & "$(Build.SourcesDirectory)/scripts/Generate-Github-Token.ps1" -AppClientId $env:GhAppId -AppPrivateKeyContents $env:GhAppKey -Repository "MicrosoftDocs/microsoftgraph-docs-powershell" + if ([string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { throw "Failed to generate GitHub App installation token." } + # Mask the token so it is never surfaced in pipeline logs. + Write-Host "##vso[task.setsecret]$env:GITHUB_TOKEN" git status - git push "https://$(GITHUB_TOKEN)@github.com/MicrosoftDocs/microsoftgraph-docs-powershell.git" gh pr create -t $Title -b $Body -B $BaseBranchName -H $Head diff --git a/azure-pipelines/powershell-docs.yml b/azure-pipelines/powershell-docs.yml index 1ef990faec264..c0304ad79ada1 100644 --- a/azure-pipelines/powershell-docs.yml +++ b/azure-pipelines/powershell-docs.yml @@ -166,10 +166,17 @@ extends: pwsh: true filePath: scripts/StabilizeMsDate.ps1 errorActionPreference: 'stop' + - task: AzureKeyVault@2 + displayName: "Azure Key Vault: Get GitHub App secrets" + inputs: + azureSubscription: "Federated AKV Managed Identity Connection" + KeyVaultName: akv-prod-eastus + SecretsFilter: "microsoft-graph-devx-bot-appid,microsoft-graph-devx-bot-privatekey" - task: PowerShell@2 displayName: Pushing to github env: - GITHUB_TOKEN: $(GITHUB_TOKEN) + GhAppId: $(microsoft-graph-devx-bot-appid) + GhAppKey: $(microsoft-graph-devx-bot-privatekey) inputs: targetType: inline pwsh: true @@ -177,10 +184,15 @@ extends: git config --global user.email "GraphTooling@service.microsoft.com" git config --global user.name "Microsoft Graph DevX Tooling" git config --system core.longpaths true + # The microsoft-graph-devx-bot GitHub App must have contents:write on this repo. + $token = & "$(Build.SourcesDirectory)/scripts/Generate-Github-Token.ps1" -AppClientId $env:GhAppId -AppPrivateKeyContents $env:GhAppKey -Repository "MicrosoftDocs/microsoftgraph-docs-powershell" + if ([string]::IsNullOrWhiteSpace($token)) { throw "Failed to generate GitHub App installation token." } + # Mask the token so it is never surfaced in pipeline logs. + Write-Host "##vso[task.setsecret]$token" git status git add . git commit -m "Updating reference docs" - git push --set-upstream "https://$(GITHUB_TOKEN)@github.com/MicrosoftDocs/microsoftgraph-docs-powershell.git" $(ComputeBranch.WeeklyReferenceDocsBranch) + git push --set-upstream "https://x-access-token:$token@github.com/MicrosoftDocs/microsoftgraph-docs-powershell.git" $(ComputeBranch.WeeklyReferenceDocsBranch) git status - template: azure-pipelines/common-templates/create-pr.yml@self parameters: diff --git a/scripts/Generate-Github-Token.ps1 b/scripts/Generate-Github-Token.ps1 new file mode 100644 index 0000000000000..0b1c041607606 --- /dev/null +++ b/scripts/Generate-Github-Token.ps1 @@ -0,0 +1,201 @@ +[CmdletBinding()] +param ( + [Parameter(Mandatory = $true)] + [string] + $AppClientId, + [Parameter(Mandatory = $true)] + [string] + $AppPrivateKeyContents, + [Parameter(Mandatory = $true)] + [ValidatePattern('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$', ErrorMessage = "Repository must be in the format 'owner/repo' (e.g. 'octocat/hello-world')")] + [string] + $Repository +) + +$ErrorActionPreference = "Stop" + +function Generate-AppToken { + param ( + [string] + $ClientId, + [string] + $PrivateKeyContents + ) + + $header = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject @{ + alg = "RS256" + typ = "JWT" + }))).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + $payload = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject @{ + iat = [System.DateTimeOffset]::UtcNow.AddSeconds(-10).ToUnixTimeSeconds() + exp = [System.DateTimeOffset]::UtcNow.AddMinutes(1).ToUnixTimeSeconds() + iss = $ClientId + }))).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + $rsa = [System.Security.Cryptography.RSA]::Create() + $rsa.ImportFromPem($PrivateKeyContents) + + $signature = [Convert]::ToBase64String($rsa.SignData([System.Text.Encoding]::UTF8.GetBytes("$header.$payload"), [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)).TrimEnd('=').Replace('+', '-').Replace('/', '_') + $jwt = "$header.$payload.$signature" + + return $jwt +} + +function Generate-InstallationToken { + param ( + [string] + $AppToken, + [string] + $InstallationId, + [string] + $Repository + ) + + $uri = "https://api.github.com/app/installations/$InstallationId/access_tokens" + $headers = @{ + Authorization = "Bearer $AppToken" + Accept = "application/vnd.github+json" + "X-GitHub-Api-Version" = "2022-11-28" + } + + $body = @{ + repositories = @($Repository) + } + + $response = Invoke-RestMethod -Uri $uri -Method Post -Headers $headers -Body (ConvertTo-Json -InputObject $body -Compress -Depth 10) + + return $response.token +} + + +function Get-OrganizationInstallationId { + param ( + [string] + $AppToken, + [string] + $Organization + ) + + $uri = "https://api.github.com/orgs/$Organization/installation" + $headers = @{ + Authorization = "Bearer $AppToken" + Accept = "application/vnd.github+json" + "X-GitHub-Api-Version" = "2022-11-28" + } + + try { + $response = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers + + return $response.id + } + catch [Microsoft.PowerShell.Commands.HttpResponseException] { + if ($_.Exception.Response.StatusCode -eq [System.Net.HttpStatusCode]::UnprocessableContent -or $_.Exception.Response.StatusCode -eq [System.Net.HttpStatusCode]::NotFound) { + return $null + } + + throw + } +} + +function Get-RepositoryInstallationId { + param ( + [string] + $AppToken, + [string] + $Repository + ) + + $uri = "https://api.github.com/repos/$Repository/installation" + $headers = @{ + Authorization = "Bearer $AppToken" + Accept = "application/vnd.github+json" + "X-GitHub-Api-Version" = "2022-11-28" + } + + try { + $response = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers + + return $response.id + } + catch [Microsoft.PowerShell.Commands.HttpResponseException] { + if ($_.Exception.Response.StatusCode -eq [System.Net.HttpStatusCode]::UnprocessableContent -or $_.Exception.Response.StatusCode -eq [System.Net.HttpStatusCode]::NotFound) { + return $null + } + + throw + } +} + +function Get-UserInstallationId { + param ( + [string] + $AppToken, + [string] + $Username + ) + + $uri = "https://api.github.com/users/$Username/installation" + $headers = @{ + Authorization = "Bearer $AppToken" + Accept = "application/vnd.github+json" + "X-GitHub-Api-Version" = "2022-11-28" + } + + try { + $response = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers + + return $response.id + } + catch [Microsoft.PowerShell.Commands.HttpResponseException] { + if ($_.Exception.Response.StatusCode -eq [System.Net.HttpStatusCode]::UnprocessableContent -or $_.Exception.Response.StatusCode -eq [System.Net.HttpStatusCode]::NotFound) { + return $null + } + + throw + } +} + +function Get-InstallationId { + param ( + [string] + $AppToken, + [string] + $Owner, + [string] + $Repo + ) + + $orgInstallationId = Get-OrganizationInstallationId -AppToken $AppToken -Organization $Owner + + if ($null -eq $orgInstallationId) { + $repoInstallationId = Get-RepositoryInstallationId -AppToken $AppToken -Repository "$Owner/$Repo" + } + else { + return $orgInstallationId + } + + if ($null -eq $repoInstallationId) { + $userInstallationId = Get-UserInstallationId -AppToken $AppToken -Username $Owner + } + else { + return $repoInstallationId + } + + if ($null -eq $userInstallationId) { + throw "Installation not found for repository '$Repo'" + } + else { + return $userInstallationId + } +} + +$owner, $repo = $Repository -split '/' + +$AppToken = Generate-AppToken -ClientId $AppClientId -PrivateKeyContents $AppPrivateKeyContents + +$InstallationId = Get-InstallationId -AppToken $AppToken -Owner $owner -Repo $repo + +$InstallationToken = Generate-InstallationToken -AppToken $AppToken -InstallationId $InstallationId -Repository $repo + +Write-Output $InstallationToken \ No newline at end of file