Skip to content

Add GitHub App installation-token auth for OneLocBuild - #17219

Merged
missymessa merged 9 commits into
mainfrom
missymessa/oneloc-github-app-token
Aug 3, 2026
Merged

Add GitHub App installation-token auth for OneLocBuild#17219
missymessa merged 9 commits into
mainfrom
missymessa/oneloc-github-app-token

Conversation

@missymessa

@missymessa missymessa commented Jul 29, 2026

Copy link
Copy Markdown
Member

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 classic
PATs whose lifetime is > 8 days, so this token has to be re-rotated every few days or the loc build
breaks with a 403:

The 'Microsoft Open Source' enterprise forbids access via a personal access tokens (classic) if the
token's lifetime is greater than 8 days.

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 using
GithubPat exactly as today. A pipeline opts in by setting GitHubAppServiceConnection
(+ client id / vault / key). The mint step and the gitHubPatVariable switch are both gated on
GitHubAppServiceConnection != '' AND TeamProject == internal.

New files (eng/common):

  • Get-GitHubAppToken.ps1 — builds an RS256 JWT, signs it with az keyvault key sign, exchanges it
    for an installation token, and sets a secret pipeline variable. No private key ever leaves Key Vault.
  • core-templates/steps/get-github-app-token.ymlAzureCLI@2 step wrapper (1ES-aware).
  • templates/steps/get-github-app-token.yml + templates-official/steps/get-github-app-token.yml
    is1ESPipeline false/true thin wrappers.

Modified:

  • core-templates/job/onelocbuild.yml — new params, the conditional mint step, and the
    backward-compatible gitHubPatVariable switch. installationOwner = existing GitHubOrg param.

Validation

  • PowerShell script parses clean; all YAML parses clean.
  • End-to-end proven in a live dnceng/internal pipeline run: an AzureCLI@2 step using a WIF
    service connection ran az keyvault key sign → minted a real ghs_ installation token for the
    dotnet app install. Log output: GET /app slug: dotnet-oneloc-localization,
    installation token minted: prefix=ghs_, SUCCESS.
  • Installation token write access confirmed (contents:write + pull_requests:write) against a
    live 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.

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
Copilot AI review requested due to automatic review settings July 29, 2026 17:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.ps1 to 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.yml to 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.

Comment thread eng/common/Get-GitHubAppToken.ps1
@missymessa
missymessa marked this pull request as ready for review July 29, 2026 17:12
@missymessa
missymessa requested review from garath, mmitche and premun July 29, 2026 17:14
….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
Copilot AI review requested due to automatic review settings July 29, 2026 18:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 OutputVariableName is 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-Object and then checks $null -eq $installation. In PowerShell, Where-Object returns 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,

Copilot AI review requested due to automatic review settings July 29, 2026 20:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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-Object returns an empty array when there are no matches, not $null, so the current $null -eq $installation check 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
}

Copilot AI review requested due to automatic review settings July 29, 2026 21:52
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 OutputVariableName is not set, the header comment says the token is written to stdout, but the script uses Write-Host, which does not write to the success output stream and is awkward to capture/pipe in local usage. Use Write-Output here so the token is actually emitted on stdout.
else {
    Write-Host $tokenResponse.token -ForegroundColor Green
}

Comment thread eng/common/Get-GitHubAppToken.ps1
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
Copilot AI review requested due to automatic review settings July 29, 2026 23:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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/installations is 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, $InstallationOwner may 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 -OutputVariableName is 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 {

Copilot AI review requested due to automatic review settings July 29, 2026 23:40
…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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 OutputVariableName is not set, the script uses Write-Host to emit the token. Write-Host does 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 use Write-Output for 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/installations is paginated (defaults to 30 per page). If the GitHub App is installed on more than one page of accounts, the desired InstallationOwner may 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
}

Comment thread eng/common/core-templates/job/onelocbuild.yml Outdated
Comment thread Documentation/OneLocBuildGitHubApp.md Outdated

@mmitche mmitche left a comment

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.

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
Copilot AI review requested due to automatic review settings July 30, 2026 16:45
@missymessa

missymessa commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

@mmitche GitHub App installation tokens are valid for one hour. I documented that explicitly in 8eb5e58 and linked the GitHub installation-token documentation.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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/installations is 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
}

Copilot AI review requested due to automatic review settings July 30, 2026 16:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 sign output format depends on the caller’s Azure CLI core.output setting. Parsing the full output as JSON (ConvertFrom-Json) can break if the CLI is configured for table/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/installations and 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

  • gitHubPatVariable selection should use the same gating as the token-minting step. As written, if UseGitHubAppAuthentication is 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 target InstallationOwner and 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 sign output is parsed as JSON without forcing the output format. If the Azure CLI output mode is changed (e.g., via config), ConvertFrom-Json can fail. You can make this more robust by querying just the signature field 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 UseGitHubAppAuthentication and System.TeamProject. If a consumer sets UseGitHubAppAuthentication: true but 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:

@missymessa
missymessa merged commit ad771c5 into main Aug 3, 2026
11 checks passed
@missymessa
missymessa deleted the missymessa/oneloc-github-app-token branch August 3, 2026 16:27
@dotnet-milestone-bot dotnet-milestone-bot Bot added this to the 11.0-rc1 milestone Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants