Add GitHub App installation-token auth for OneLocBuild - #17219
Conversation
Replace the long-lived GitHub classic PAT used for the OneLoc localization check-in with a short-lived GitHub App installation token, minted at build time by signing a JWT with an RSA key in Azure Key Vault. Installation tokens are exempt from the enterprise policy that forbids classic PATs older than 8 days, which has been recurrently breaking OneLoc builds. The change is opt-in and backward compatible: new parameters default to '' and the job keeps using GithubPat until a pipeline sets GitHubAppServiceConnection (plus client id / vault / key). Mirrors the existing Ceapex federated-token switch and dotnet/arcade-services #6394. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a2aa4598-efc5-44f4-a67b-7db19982252d
There was a problem hiding this comment.
Pull request overview
Adds optional GitHub App installation-token authentication to the OneLocBuild job, allowing pipelines to mint short-lived ghs_* tokens via Azure Key Vault key signing and use them in place of long-lived classic PATs for the localization check-in PR flow.
Changes:
- Introduces
Get-GitHubAppToken.ps1to build/sign an RS256 JWT via Key Vault and exchange it for a GitHub App installation token, optionally storing it as a secret pipeline variable. - Adds core + shim YAML templates to run the token-minting step via
AzureCLI@2(standard and 1ES entry points). - Updates
core-templates/job/onelocbuild.ymlto optionally mint and use the installation token when configured (otherwise preserves existing PAT-based behavior).
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| eng/common/templates/steps/get-github-app-token.yml | Non-1ES shim entry point to the core token-minting step template. |
| eng/common/templates-official/steps/get-github-app-token.yml | 1ES shim entry point to the core token-minting step template. |
| eng/common/Get-GitHubAppToken.ps1 | New script that signs a JWT using a Key Vault RSA key and mints a GitHub App installation token. |
| eng/common/core-templates/steps/get-github-app-token.yml | Core AzureCLI step wrapper that runs the token-minting PowerShell script. |
| eng/common/core-templates/job/onelocbuild.yml | Adds opt-in parameters and switches gitHubPatVariable to the minted installation token when enabled for internal builds. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
….ps1 Fixes the arcade-pr CI failure (configure-toolset.ps1 requires every eng/common/*.ps1 to use Write-PipelineTelemetryError) and addresses PR review feedback by emitting clear, categorized errors when 'az keyvault key sign' or the GitHub API calls fail (checking \0 and an empty signature) instead of surfacing an opaque JSON/convert error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a2aa4598-efc5-44f4-a67b-7db19982252d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
eng/common/Get-GitHubAppToken.ps1:139
- When
OutputVariableNameis not set, the script writes the installation token to stdout, which will typically end up in pipeline logs (secret masking is not guaranteed for all log paths/consumers). It’s safer to require output via a secret pipeline variable when running under Azure Pipelines, while still allowing stdout for local/manual runs.
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
}
eng/common/Get-GitHubAppToken.ps1:118
- The installation lookup uses
Where-Objectand then checks$null -eq $installation. In PowerShell,Where-Objectreturns an empty array when there are no matches, so this check won’t catch the “not installed” case and can lead to a malformed access-token request. Also, listing all installations can require pagination; GitHub provides dedicated endpoints to fetch the installation for a specific org/user without paging.
Write-Host "Looking up installation for '$InstallationOwner'..."
try {
$installations = Invoke-RestMethod -Uri 'https://api.github.com/app/installations' -Headers $headers -Method Get
}
catch {
eng/common/Get-GitHubAppToken.ps1:34
- The mandatory parameters allow empty strings (e.g., caller sets GitHubAppServiceConnection but forgets key name/vault/client id). That leads to later failures with less actionable errors. Adding
ValidateNotNullOrEmpty()makes the script fail fast with a clear parameter-binding error.
# Name of the Key Vault that holds the GitHub App's RSA signing key.
[Parameter(Mandatory = $true)]
[string] $KeyVaultName,
# Name of the RSA key inside the Key Vault (the App's private key).
[Parameter(Mandatory = $true)]
[string] $KeyName,
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
eng/common/Get-GitHubAppToken.ps1:118
Where-Objectreturns an empty array when there are no matches, not$null, so the current$null -eq $installationcheck can be bypassed. That can lead to building an access_tokens URL with an empty installation id and a misleading failure. Select the first match and use a truthy check instead.
$installation = $installations | Where-Object { $_.account.login -eq $InstallationOwner }
if ($null -eq $installation) {
$found = ($installations | ForEach-Object { $_.account.login }) -join ', '
Write-PipelineTelemetryError -Category 'Build' -Message "No installation found for '$InstallationOwner'. App is installed on: $found"
exit 1
}
Where-Object returns an empty array (not $null) when nothing matches, so the previous $null -eq guard could be bypassed and build an access_tokens URL with an empty installation id. Select the first match and use a truthy check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a2aa4598-efc5-44f4-a67b-7db19982252d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
eng/common/Get-GitHubAppToken.ps1:139
- When
OutputVariableNameis not set, the header comment says the token is written to stdout, but the script usesWrite-Host, which does not write to the success output stream and is awkward to capture/pipe in local usage. UseWrite-Outputhere so the token is actually emitted on stdout.
else {
Write-Host $tokenResponse.token -ForegroundColor Green
}
Add Documentation/OneLocBuildGitHubApp.md explaining how repos gain access to the 'dotnet OneLoc Localization' GitHub App and opt in to short-lived installation-token auth for the loc check-in PR, and cross-link it from OneLocBuild.md plus document the new GitHubApp* template parameters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a2aa4598-efc5-44f4-a67b-7db19982252d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
eng/common/Get-GitHubAppToken.ps1:118
GET https://api.github.com/app/installationsis a paginated endpoint (default page size is limited), but the script only requests the first page. If the app ends up installed on enough orgs/users,$InstallationOwnermay not appear in the first page and this will incorrectly fail with "No installation found".
Consider doing a direct installation lookup for the specific org/user (or implement pagination) so the script stays robust as the app is installed more widely.
Write-Host "Looking up installation for '$InstallationOwner'..."
try {
$installations = Invoke-RestMethod -Uri 'https://api.github.com/app/installations' -Headers $headers -Method Get
}
catch {
eng/common/Get-GitHubAppToken.ps1:139
- If
-OutputVariableNameis omitted or passed as an empty string, the script writes the installation token to stdout. In an Azure Pipelines context this risks leaking the token into logs if a caller misconfigures the parameter.
Safer default behavior is to require an output variable name when running under Azure Pipelines (while still allowing stdout output for local debugging).
if ($OutputVariableName) {
Write-Host "Setting pipeline variable '$OutputVariableName'."
Write-Host "##vso[task.setvariable variable=$OutputVariableName;issecret=true]$($tokenResponse.token)"
}
else {
…tion Reframe the PAT path as a temporary migration fallback rather than a permanent option: the shared BotAccount-dotnet-bot-repo-PAT will no longer be maintained once the GitHub App path is verified, and every GitHub-based repo must migrate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a2aa4598-efc5-44f4-a67b-7db19982252d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
eng/common/Get-GitHubAppToken.ps1:139
- When
OutputVariableNameis not set, the script usesWrite-Hostto emit the token.Write-Hostdoes not write to the success output stream (so callers can't capture the value despite the comment saying it writes to stdout), and in pipeline scenarios it risks leaking the installation token into logs. Consider failing in Azure Pipelines when no output variable name is provided, and useWrite-Outputfor local/caller consumption.
Write-Host "##vso[task.setvariable variable=$OutputVariableName;issecret=true]$($tokenResponse.token)"
}
else {
Write-Host $tokenResponse.token -ForegroundColor Green
}
eng/common/Get-GitHubAppToken.ps1:118
GET https://api.github.com/app/installationsis paginated (defaults to 30 per page). If the GitHub App is installed on more than one page of accounts, the desiredInstallationOwnermay not be returned and token minting will fail even though an installation exists. Prefer querying the specific installation endpoint(s) for the owner (org/user) to avoid paging issues.
Write-Host "Looking up installation for '$InstallationOwner'..."
try {
$installations = Invoke-RestMethod -Uri 'https://api.github.com/app/installations' -Headers $headers -Method Get
}
mmitche
left a comment
There was a problem hiding this comment.
What is the installation token validity period?
Centralize the dnceng GitHub App infrastructure defaults behind a single opt-in flag, clarify the DevDiv provisioning requirement, and simplify the documentation around current App behavior and the one-hour installation token lifetime. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f12710df-91d5-49e4-b327-337932478b0b
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
eng/common/Get-GitHubAppToken.ps1:139
- The script prints the minted installation token to stdout when -OutputVariableName is omitted. Even though the token is short-lived, it can still grant write access and would be visible in CI logs or local shells. It’s safer to refuse to print the token and require callers to pass OutputVariableName so the token is stored as a secret pipeline variable.
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
}
eng/common/Get-GitHubAppToken.ps1:109
GET https://api.github.com/app/installationsis a paginated endpoint. This code only requests the default first page, so it can fail to find the requested InstallationOwner if the app is installed on more than the first page of accounts. At minimum, request the maximum page size to reduce the risk of missing the installation.
Write-Host "Looking up installation for '$InstallationOwner'..."
try {
$installations = Invoke-RestMethod -Uri 'https://api.github.com/app/installations' -Headers $headers -Method Get
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
eng/common/Get-GitHubAppToken.ps1:96
az keyvault key signoutput format depends on the caller’s Azure CLIcore.outputsetting. Parsing the full output as JSON (ConvertFrom-Json) can break if the CLI is configured fortable/yaml, even though signing succeeded. Prefer querying the signature directly (--query signature -o tsv) to make this robust and avoid JSON parsing entirely.
$signResponseJson = az keyvault key sign `
--vault-name $KeyVaultName `
--name $KeyName `
--algorithm RS256 `
--digest $digestBase64
eng/common/Get-GitHubAppToken.ps1:118
- Installation lookup uses
GET /app/installationsand then filters client-side. That endpoint is paginated, so if the app is installed on enough accounts the target owner may be on a later page and this script will incorrectly report “No installation found”. GitHub provides a direct lookup endpoint for the app’s installation on an org/user, which avoids pagination and removes the need for case-sensitive login matching.
Write-Host "Looking up installation for '$InstallationOwner'..."
try {
$installations = Invoke-RestMethod -Uri 'https://api.github.com/app/installations' -Headers $headers -Method Get
}
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."
exit 1
}
$installation = $installations | Where-Object { $_.account.login -eq $InstallationOwner } | Select-Object -First 1
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (4)
eng/common/core-templates/job/onelocbuild.yml:138
gitHubPatVariableselection should use the same gating as the token-minting step. As written, ifUseGitHubAppAuthenticationis true but the GitHub App infrastructure parameters are unset/empty (or the mint step is skipped), the template can still try to use$(GitHubAppInstallationToken). Align the conditions so PAT auth is used whenever the App auth prerequisites aren't satisfied.
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 }}"
eng/common/Get-GitHubAppToken.ps1:118
- The script lists installations via
GET /app/installations, which is paginated (default page size is limited). If the App has more than one page of installations, this lookup can miss the targetInstallationOwnerand fail even though an installation exists. Prefer the direct installation lookup endpoint for the owner (org/user) to avoid pagination.
Write-Host "Looking up installation for '$InstallationOwner'..."
try {
$installations = Invoke-RestMethod -Uri 'https://api.github.com/app/installations' -Headers $headers -Method Get
}
catch {
eng/common/Get-GitHubAppToken.ps1:96
az keyvault key signoutput is parsed as JSON without forcing the output format. If the Azure CLI output mode is changed (e.g., via config),ConvertFrom-Jsoncan fail. You can make this more robust by querying just thesignaturefield and using--output tsv, avoiding JSON parsing entirely.
$signResponseJson = az keyvault key sign `
--vault-name $KeyVaultName `
--name $KeyName `
--algorithm RS256 `
--digest $digestBase64
eng/common/core-templates/job/onelocbuild.yml:104
- The GitHub App auth path is gated only on
UseGitHubAppAuthenticationandSystem.TeamProject. If a consumer setsUseGitHubAppAuthentication: truebut overrides any required GitHub App infrastructure parameter (service connection/client id/key vault/key) to empty, the job will still try to run the AzureCLI step and/or reference$(GitHubAppInstallationToken), leading to runtime failures. Gate the App path on the required parameters being non-empty so the template reliably falls back to PAT auth when not fully configured.
This issue also appears on line 134 of the same file.
# 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')) }}:
- template: /eng/common/templates/steps/get-github-app-token.yml
parameters:
Add GitHub App installation-token auth for OneLocBuild
Why
OneLocBuild authenticates its localization check-in PR to GitHub with a classic PAT
(
BotAccount-dotnet-bot-repo-PAT). The Microsoft Open Source enterprise now forbids classicPATs whose lifetime is > 8 days, so this token has to be re-rotated every few days or the loc build
breaks with a
403:GitHub App installation tokens (
ghs_) are short-lived (~1h) and exempt from that policy.This change lets a pipeline mint one at build time via Azure Key Vault key signing, removing the
recurring breakage and the manual PAT rotation toil.
Mirrors the approach in dotnet/arcade-services#6394 and the existing Ceapex federated-token switch
already present in
onelocbuild.yml.What
Opt-in and fully backward compatible. New params default to
''; when unset the job keeps usingGithubPatexactly as today. A pipeline opts in by settingGitHubAppServiceConnection(+ client id / vault / key). The mint step and the
gitHubPatVariableswitch are both gated onGitHubAppServiceConnection != '' AND TeamProject == internal.New files (
eng/common):Get-GitHubAppToken.ps1— builds an RS256 JWT, signs it withaz keyvault key sign, exchanges itfor an installation token, and sets a secret pipeline variable. No private key ever leaves Key Vault.
core-templates/steps/get-github-app-token.yml—AzureCLI@2step wrapper (1ES-aware).templates/steps/get-github-app-token.yml+templates-official/steps/get-github-app-token.yml—
is1ESPipelinefalse/true thin wrappers.Modified:
core-templates/job/onelocbuild.yml— new params, the conditional mint step, and thebackward-compatible
gitHubPatVariableswitch.installationOwner= existingGitHubOrgparam.Validation
AzureCLI@2step using a WIFservice connection ran
az keyvault key sign→ minted a realghs_installation token for thedotnet app install. Log output:
GET /app slug: dotnet-oneloc-localization,installation token minted: prefix=ghs_,SUCCESS.contents:write+pull_requests:write) against alive dotnet-org repo.
Rollout note (does not block this PR)
The template change is opt-in and backward compatible, so it can merge independently. Enabling it for
a given pipeline additionally requires that consumer to provision a GitHub App (installed on the
target org), store its key in a Key Vault, and wire up a WIF service connection with Key Vault
Crypto User on the key. For the dnceng OneLoc pilot, the dotnet-org install is live; the microsoft
-org install is pending org-owner approval.