Skip to content

Commit b91001f

Browse files
authored
Merge branch 'main' into fix/agent-framework-tool-default
2 parents c7e75d3 + ca40aa7 commit b91001f

44 files changed

Lines changed: 6166 additions & 5029 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
name: Get GitHub automation token
2+
description: Creates a GitHub App installation token with a temporary PAT fallback
3+
4+
inputs:
5+
mode:
6+
description: Authentication mode (app, app-with-fallback, or pat)
7+
required: false
8+
default: app-with-fallback
9+
azure-client-id:
10+
description: Client ID of the Azure workload identity
11+
required: false
12+
azure-tenant-id:
13+
description: Azure tenant ID
14+
required: false
15+
azure-subscription-id:
16+
description: Azure subscription containing the Key Vault
17+
required: false
18+
key-vault-name:
19+
description: Azure Key Vault name
20+
required: false
21+
key-name:
22+
description: Key Vault key used to sign the GitHub App JWT
23+
required: false
24+
github-app-client-id:
25+
description: GitHub App client ID
26+
required: false
27+
github-app-installation-id:
28+
description: GitHub App installation ID
29+
required: false
30+
repository:
31+
description: Repository to include in the installation token
32+
required: false
33+
permission-profile:
34+
description: Permission profile (issues, pull-requests, or devflow)
35+
required: true
36+
fallback-token:
37+
description: PAT used temporarily when app authentication is unavailable
38+
required: false
39+
40+
outputs:
41+
token:
42+
description: GitHub App installation token or fallback PAT
43+
value: ${{ steps.select-token.outputs.token }}
44+
source:
45+
description: Selected authentication source
46+
value: ${{ steps.select-token.outputs.source }}
47+
48+
runs:
49+
using: composite
50+
steps:
51+
- name: Validate authentication mode
52+
shell: bash
53+
env:
54+
AUTH_MODE: ${{ inputs.mode || 'app-with-fallback' }}
55+
run: |
56+
if [[ "$AUTH_MODE" != "app" && "$AUTH_MODE" != "app-with-fallback" && "$AUTH_MODE" != "pat" ]]; then
57+
echo "::error::Unsupported GitHub authentication mode."
58+
exit 1
59+
fi
60+
61+
- name: Sign in to Azure
62+
id: azure-login
63+
if: ${{ (inputs.mode || 'app-with-fallback') != 'pat' }}
64+
continue-on-error: true
65+
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2
66+
with:
67+
client-id: ${{ inputs.azure-client-id }}
68+
tenant-id: ${{ inputs.azure-tenant-id }}
69+
subscription-id: ${{ inputs.azure-subscription-id }}
70+
71+
- name: Create GitHub App installation token
72+
id: app-token
73+
if: ${{ (inputs.mode || 'app-with-fallback') != 'pat' && steps.azure-login.outcome == 'success' }}
74+
continue-on-error: true
75+
shell: bash
76+
env:
77+
AZURE_SUBSCRIPTION_ID: ${{ inputs.azure-subscription-id }}
78+
KEY_VAULT_NAME: ${{ inputs.key-vault-name }}
79+
KEY_NAME: ${{ inputs.key-name }}
80+
GITHUB_APP_CLIENT_ID: ${{ inputs.github-app-client-id }}
81+
GITHUB_APP_INSTALLATION_ID: ${{ inputs.github-app-installation-id }}
82+
TARGET_REPOSITORY: ${{ inputs.repository }}
83+
PERMISSION_PROFILE: ${{ inputs.permission-profile }}
84+
run: |
85+
token="$(node "$GITHUB_ACTION_PATH/create-token.js")"
86+
echo "::add-mask::$token"
87+
echo "token=$token" >> "$GITHUB_OUTPUT"
88+
89+
- name: Select authentication token
90+
id: select-token
91+
shell: bash
92+
env:
93+
AUTH_MODE: ${{ inputs.mode || 'app-with-fallback' }}
94+
APP_TOKEN: ${{ steps.app-token.outputs.token }}
95+
FALLBACK_TOKEN: ${{ inputs.fallback-token }}
96+
run: |
97+
if [[ "$AUTH_MODE" != "pat" && -n "$APP_TOKEN" ]]; then
98+
token="$APP_TOKEN"
99+
source="app"
100+
echo "::notice::GitHub authentication source: app"
101+
elif [[ "$AUTH_MODE" == "app-with-fallback" && -n "$FALLBACK_TOKEN" ]]; then
102+
token="$FALLBACK_TOKEN"
103+
source="pat-fallback"
104+
echo "::warning::GitHub authentication source: PAT fallback"
105+
elif [[ "$AUTH_MODE" == "pat" && -n "$FALLBACK_TOKEN" ]]; then
106+
token="$FALLBACK_TOKEN"
107+
source="pat-forced"
108+
echo "::warning::GitHub authentication source: PAT (forced rollout mode)"
109+
else
110+
echo "::error::GitHub App authentication is unavailable and no fallback PAT was provided."
111+
exit 1
112+
fi
113+
114+
echo "::add-mask::$token"
115+
echo "token=$token" >> "$GITHUB_OUTPUT"
116+
echo "source=$source" >> "$GITHUB_OUTPUT"
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
const crypto = require('node:crypto');
4+
const { execFileSync } = require('node:child_process');
5+
6+
const PERMISSION_PROFILES = Object.freeze({
7+
issues: Object.freeze({
8+
issues: 'write',
9+
}),
10+
'pull-requests': Object.freeze({
11+
contents: 'read',
12+
pull_requests: 'write',
13+
}),
14+
devflow: Object.freeze({
15+
contents: 'read',
16+
issues: 'write',
17+
pull_requests: 'write',
18+
}),
19+
});
20+
21+
function base64Url(value) {
22+
return Buffer.from(value).toString('base64url');
23+
}
24+
25+
function base64ToBase64Url(value) {
26+
return Buffer.from(value, 'base64').toString('base64url');
27+
}
28+
29+
function createJwtSigningInput(clientId, nowSeconds) {
30+
const header = base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
31+
const payload = base64Url(JSON.stringify({
32+
iat: nowSeconds - 60,
33+
exp: nowSeconds + 540,
34+
iss: clientId,
35+
}));
36+
return `${header}.${payload}`;
37+
}
38+
39+
function signJwt(signingInput, config, execute = execFileSync) {
40+
const digest = crypto.createHash('sha256').update(signingInput).digest('base64');
41+
const signature = execute(
42+
'az',
43+
[
44+
'keyvault', 'key', 'sign',
45+
'--subscription', config.azureSubscriptionId,
46+
'--vault-name', config.keyVaultName,
47+
'--name', config.keyName,
48+
'--algorithm', 'RS256',
49+
'--digest', digest,
50+
'--query', 'signature',
51+
'--output', 'tsv',
52+
'--only-show-errors',
53+
],
54+
{ encoding: 'utf8' },
55+
).trim();
56+
57+
if (!signature) {
58+
throw new Error('Key Vault returned an empty signature.');
59+
}
60+
61+
return `${signingInput}.${base64ToBase64Url(signature)}`;
62+
}
63+
64+
async function createInstallationToken(config, dependencies = {}) {
65+
const execute = dependencies.execute ?? execFileSync;
66+
const request = dependencies.fetch ?? fetch;
67+
const nowSeconds = dependencies.nowSeconds ?? Math.floor(Date.now() / 1000);
68+
const repositoryParts = config.targetRepository.split('/');
69+
70+
if (repositoryParts.length !== 2 || repositoryParts.some((part) => part.length === 0)) {
71+
throw new Error('TARGET_REPOSITORY must use the owner/repository format.');
72+
}
73+
if (!Object.hasOwn(PERMISSION_PROFILES, config.permissionProfile)) {
74+
throw new Error('PERMISSION_PROFILE must be issues, pull-requests, or devflow.');
75+
}
76+
const permissions = PERMISSION_PROFILES[config.permissionProfile];
77+
78+
const [, repository] = repositoryParts;
79+
const signingInput = createJwtSigningInput(config.githubAppClientId, nowSeconds);
80+
const jwt = signJwt(signingInput, config, execute);
81+
82+
const response = await request(
83+
`https://api.github.com/app/installations/${config.githubAppInstallationId}/access_tokens`,
84+
{
85+
method: 'POST',
86+
headers: {
87+
Accept: 'application/vnd.github+json',
88+
Authorization: `Bearer ${jwt}`,
89+
'X-GitHub-Api-Version': '2022-11-28',
90+
},
91+
body: JSON.stringify({
92+
repositories: [repository],
93+
permissions,
94+
}),
95+
},
96+
);
97+
98+
if (!response.ok) {
99+
throw new Error(`GitHub installation token request failed with HTTP ${response.status}.`);
100+
}
101+
102+
const result = await response.json();
103+
if (typeof result.token !== 'string' || result.token.length === 0) {
104+
throw new Error('GitHub returned an empty installation token.');
105+
}
106+
107+
return result.token;
108+
}
109+
110+
function readConfig(environment) {
111+
const config = {
112+
azureSubscriptionId: environment.AZURE_SUBSCRIPTION_ID,
113+
keyVaultName: environment.KEY_VAULT_NAME,
114+
keyName: environment.KEY_NAME,
115+
githubAppClientId: environment.GITHUB_APP_CLIENT_ID,
116+
githubAppInstallationId: environment.GITHUB_APP_INSTALLATION_ID,
117+
targetRepository: environment.TARGET_REPOSITORY,
118+
permissionProfile: environment.PERMISSION_PROFILE,
119+
};
120+
121+
if (Object.values(config).some((value) => !value)) {
122+
throw new Error('Required GitHub App authentication configuration is missing.');
123+
}
124+
125+
return config;
126+
}
127+
128+
async function main() {
129+
try {
130+
const token = await createInstallationToken(readConfig(process.env));
131+
process.stdout.write(token);
132+
} catch {
133+
console.error('GitHub App token generation failed.');
134+
process.exitCode = 1;
135+
}
136+
}
137+
138+
if (require.main === module) {
139+
void main();
140+
}
141+
142+
module.exports = {
143+
base64ToBase64Url,
144+
createInstallationToken,
145+
createJwtSigningInput,
146+
readConfig,
147+
signJwt,
148+
PERMISSION_PROFILES,
149+
};

0 commit comments

Comments
 (0)