diff --git a/.github/actions/github-app-token/action.yml b/.github/actions/github-app-token/action.yml new file mode 100644 index 000000000000..1447b4ea4399 --- /dev/null +++ b/.github/actions/github-app-token/action.yml @@ -0,0 +1,116 @@ +name: Get GitHub automation token +description: Creates a GitHub App installation token with a temporary PAT fallback + +inputs: + mode: + description: Authentication mode (app, app-with-fallback, or pat) + required: false + default: app-with-fallback + azure-client-id: + description: Client ID of the Azure workload identity + required: false + azure-tenant-id: + description: Azure tenant ID + required: false + azure-subscription-id: + description: Azure subscription containing the Key Vault + required: false + key-vault-name: + description: Azure Key Vault name + required: false + key-name: + description: Key Vault key used to sign the GitHub App JWT + required: false + github-app-client-id: + description: GitHub App client ID + required: false + github-app-installation-id: + description: GitHub App installation ID + required: false + repository: + description: Repository to include in the installation token + required: false + permission-profile: + description: Permission profile (issues, pull-requests, or devflow) + required: true + fallback-token: + description: PAT used temporarily when app authentication is unavailable + required: false + +outputs: + token: + description: GitHub App installation token or fallback PAT + value: ${{ steps.select-token.outputs.token }} + source: + description: Selected authentication source + value: ${{ steps.select-token.outputs.source }} + +runs: + using: composite + steps: + - name: Validate authentication mode + shell: bash + env: + AUTH_MODE: ${{ inputs.mode || 'app-with-fallback' }} + run: | + if [[ "$AUTH_MODE" != "app" && "$AUTH_MODE" != "app-with-fallback" && "$AUTH_MODE" != "pat" ]]; then + echo "::error::Unsupported GitHub authentication mode." + exit 1 + fi + + - name: Sign in to Azure + id: azure-login + if: ${{ (inputs.mode || 'app-with-fallback') != 'pat' }} + continue-on-error: true + uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 + with: + client-id: ${{ inputs.azure-client-id }} + tenant-id: ${{ inputs.azure-tenant-id }} + subscription-id: ${{ inputs.azure-subscription-id }} + + - name: Create GitHub App installation token + id: app-token + if: ${{ (inputs.mode || 'app-with-fallback') != 'pat' && steps.azure-login.outcome == 'success' }} + continue-on-error: true + shell: bash + env: + AZURE_SUBSCRIPTION_ID: ${{ inputs.azure-subscription-id }} + KEY_VAULT_NAME: ${{ inputs.key-vault-name }} + KEY_NAME: ${{ inputs.key-name }} + GITHUB_APP_CLIENT_ID: ${{ inputs.github-app-client-id }} + GITHUB_APP_INSTALLATION_ID: ${{ inputs.github-app-installation-id }} + TARGET_REPOSITORY: ${{ inputs.repository }} + PERMISSION_PROFILE: ${{ inputs.permission-profile }} + run: | + token="$(node "$GITHUB_ACTION_PATH/create-token.js")" + echo "::add-mask::$token" + echo "token=$token" >> "$GITHUB_OUTPUT" + + - name: Select authentication token + id: select-token + shell: bash + env: + AUTH_MODE: ${{ inputs.mode || 'app-with-fallback' }} + APP_TOKEN: ${{ steps.app-token.outputs.token }} + FALLBACK_TOKEN: ${{ inputs.fallback-token }} + run: | + if [[ "$AUTH_MODE" != "pat" && -n "$APP_TOKEN" ]]; then + token="$APP_TOKEN" + source="app" + echo "::notice::GitHub authentication source: app" + elif [[ "$AUTH_MODE" == "app-with-fallback" && -n "$FALLBACK_TOKEN" ]]; then + token="$FALLBACK_TOKEN" + source="pat-fallback" + echo "::warning::GitHub authentication source: PAT fallback" + elif [[ "$AUTH_MODE" == "pat" && -n "$FALLBACK_TOKEN" ]]; then + token="$FALLBACK_TOKEN" + source="pat-forced" + echo "::warning::GitHub authentication source: PAT (forced rollout mode)" + else + echo "::error::GitHub App authentication is unavailable and no fallback PAT was provided." + exit 1 + fi + + echo "::add-mask::$token" + echo "token=$token" >> "$GITHUB_OUTPUT" + echo "source=$source" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/github-app-token/create-token.js b/.github/actions/github-app-token/create-token.js new file mode 100644 index 000000000000..1caedf70a8c6 --- /dev/null +++ b/.github/actions/github-app-token/create-token.js @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft. All rights reserved. + +const crypto = require('node:crypto'); +const { execFileSync } = require('node:child_process'); + +const PERMISSION_PROFILES = Object.freeze({ + issues: Object.freeze({ + issues: 'write', + }), + 'pull-requests': Object.freeze({ + contents: 'read', + pull_requests: 'write', + }), + devflow: Object.freeze({ + contents: 'read', + issues: 'write', + pull_requests: 'write', + }), +}); + +function base64Url(value) { + return Buffer.from(value).toString('base64url'); +} + +function base64ToBase64Url(value) { + return Buffer.from(value, 'base64').toString('base64url'); +} + +function createJwtSigningInput(clientId, nowSeconds) { + const header = base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })); + const payload = base64Url(JSON.stringify({ + iat: nowSeconds - 60, + exp: nowSeconds + 540, + iss: clientId, + })); + return `${header}.${payload}`; +} + +function signJwt(signingInput, config, execute = execFileSync) { + const digest = crypto.createHash('sha256').update(signingInput).digest('base64'); + const signature = execute( + 'az', + [ + 'keyvault', 'key', 'sign', + '--subscription', config.azureSubscriptionId, + '--vault-name', config.keyVaultName, + '--name', config.keyName, + '--algorithm', 'RS256', + '--digest', digest, + '--query', 'signature', + '--output', 'tsv', + '--only-show-errors', + ], + { encoding: 'utf8' }, + ).trim(); + + if (!signature) { + throw new Error('Key Vault returned an empty signature.'); + } + + return `${signingInput}.${base64ToBase64Url(signature)}`; +} + +async function createInstallationToken(config, dependencies = {}) { + const execute = dependencies.execute ?? execFileSync; + const request = dependencies.fetch ?? fetch; + const nowSeconds = dependencies.nowSeconds ?? Math.floor(Date.now() / 1000); + const repositoryParts = config.targetRepository.split('/'); + + if (repositoryParts.length !== 2 || repositoryParts.some((part) => part.length === 0)) { + throw new Error('TARGET_REPOSITORY must use the owner/repository format.'); + } + if (!Object.hasOwn(PERMISSION_PROFILES, config.permissionProfile)) { + throw new Error('PERMISSION_PROFILE must be issues, pull-requests, or devflow.'); + } + const permissions = PERMISSION_PROFILES[config.permissionProfile]; + + const [, repository] = repositoryParts; + const signingInput = createJwtSigningInput(config.githubAppClientId, nowSeconds); + const jwt = signJwt(signingInput, config, execute); + + const response = await request( + `https://api.github.com/app/installations/${config.githubAppInstallationId}/access_tokens`, + { + method: 'POST', + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${jwt}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + body: JSON.stringify({ + repositories: [repository], + permissions, + }), + }, + ); + + if (!response.ok) { + throw new Error(`GitHub installation token request failed with HTTP ${response.status}.`); + } + + const result = await response.json(); + if (typeof result.token !== 'string' || result.token.length === 0) { + throw new Error('GitHub returned an empty installation token.'); + } + + return result.token; +} + +function readConfig(environment) { + const config = { + azureSubscriptionId: environment.AZURE_SUBSCRIPTION_ID, + keyVaultName: environment.KEY_VAULT_NAME, + keyName: environment.KEY_NAME, + githubAppClientId: environment.GITHUB_APP_CLIENT_ID, + githubAppInstallationId: environment.GITHUB_APP_INSTALLATION_ID, + targetRepository: environment.TARGET_REPOSITORY, + permissionProfile: environment.PERMISSION_PROFILE, + }; + + if (Object.values(config).some((value) => !value)) { + throw new Error('Required GitHub App authentication configuration is missing.'); + } + + return config; +} + +async function main() { + try { + const token = await createInstallationToken(readConfig(process.env)); + process.stdout.write(token); + } catch { + console.error('GitHub App token generation failed.'); + process.exitCode = 1; + } +} + +if (require.main === module) { + void main(); +} + +module.exports = { + base64ToBase64Url, + createInstallationToken, + createJwtSigningInput, + readConfig, + signJwt, + PERMISSION_PROFILES, +}; diff --git a/.github/tests/test_create_github_app_token.js b/.github/tests/test_create_github_app_token.js new file mode 100644 index 000000000000..04cf8099b618 --- /dev/null +++ b/.github/tests/test_create_github_app_token.js @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft. All rights reserved. + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + base64ToBase64Url, + createInstallationToken, + createJwtSigningInput, + readConfig, +} = require('../actions/github-app-token/create-token.js'); + +const CONFIG = { + azureSubscriptionId: 'subscription-id', + keyVaultName: 'vault-name', + keyName: 'key-name', + githubAppClientId: 'client-id', + githubAppInstallationId: '12345', + targetRepository: 'microsoft/semantic-kernel', + permissionProfile: 'devflow', +}; + +describe('GitHub App token creation', () => { + it('creates a short-lived GitHub App JWT', () => { + const signingInput = createJwtSigningInput('client-id', 1_000); + const [encodedHeader, encodedPayload] = signingInput.split('.'); + const header = JSON.parse(Buffer.from(encodedHeader, 'base64url').toString()); + const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString()); + + assert.deepEqual(header, { alg: 'RS256', typ: 'JWT' }); + assert.deepEqual(payload, { iat: 940, exp: 1_540, iss: 'client-id' }); + }); + + it('converts Key Vault signatures to unpadded base64url', () => { + assert.equal(base64ToBase64Url('+/8='), '-_8'); + }); + + it('requests a repository-scoped installation token', async () => { + let request; + const token = await createInstallationToken(CONFIG, { + nowSeconds: 1_000, + execute: (command, args) => { + assert.equal(command, 'az'); + assert.ok(args.includes('RS256')); + return '+/8=\n'; + }, + fetch: async (url, options) => { + request = { url, options }; + return { + ok: true, + json: async () => ({ token: 'installation-token' }), + }; + }, + }); + + assert.equal(token, 'installation-token'); + assert.equal(request.url, 'https://api.github.com/app/installations/12345/access_tokens'); + assert.match(request.options.headers.Authorization, /^Bearer [^.]+\.[^.]+\.-_8$/); + assert.deepEqual(JSON.parse(request.options.body), { + repositories: ['semantic-kernel'], + permissions: { + contents: 'read', + issues: 'write', + pull_requests: 'write', + }, + }); + }); + + it('limits issue-label tokens to issue writes', async () => { + let request; + await createInstallationToken( + { ...CONFIG, permissionProfile: 'issues' }, + { + execute: () => '+/8=\n', + fetch: async (_url, options) => { + request = options; + return { + ok: true, + json: async () => ({ token: 'installation-token' }), + }; + }, + }, + ); + + assert.deepEqual(JSON.parse(request.body).permissions, { + issues: 'write', + }); + }); + + it('limits pull-request label tokens to contents and pull requests', async () => { + let request; + await createInstallationToken( + { ...CONFIG, permissionProfile: 'pull-requests' }, + { + execute: () => '+/8=\n', + fetch: async (_url, options) => { + request = options; + return { + ok: true, + json: async () => ({ token: 'installation-token' }), + }; + }, + }, + ); + + assert.deepEqual(JSON.parse(request.body).permissions, { + contents: 'read', + pull_requests: 'write', + }); + }); + + it('rejects unknown permission profiles before signing', async () => { + let signed = false; + + await assert.rejects( + createInstallationToken( + { ...CONFIG, permissionProfile: 'everything' }, + { + execute: () => { + signed = true; + return '+/8=\n'; + }, + }, + ), + /PERMISSION_PROFILE must be issues, pull-requests, or devflow/, + ); + assert.equal(signed, false); + }); + + it('rejects inherited object properties as permission profiles before signing', async () => { + let signed = false; + + await assert.rejects( + createInstallationToken( + { ...CONFIG, permissionProfile: 'toString' }, + { + execute: () => { + signed = true; + return '+/8=\n'; + }, + }, + ), + /PERMISSION_PROFILE must be issues, pull-requests, or devflow/, + ); + assert.equal(signed, false); + }); + + it('rejects incomplete configuration', () => { + assert.throws( + () => readConfig({}), + /Required GitHub App authentication configuration is missing/, + ); + }); + + it('rejects repository values with extra path segments before signing', async () => { + let signed = false; + + await assert.rejects( + createInstallationToken( + { ...CONFIG, targetRepository: 'microsoft/semantic-kernel/extra' }, + { + execute: () => { + signed = true; + return '+/8=\n'; + }, + }, + ), + /TARGET_REPOSITORY must use the owner\/repository format/, + ); + assert.equal(signed, false); + }); + + it('rejects an empty Key Vault signature', async () => { + await assert.rejects( + createInstallationToken(CONFIG, { + execute: () => '\n', + }), + /Key Vault returned an empty signature/, + ); + }); + + it('rejects a failed GitHub token request', async () => { + await assert.rejects( + createInstallationToken(CONFIG, { + execute: () => '+/8=\n', + fetch: async () => ({ ok: false, status: 403 }), + }), + /GitHub installation token request failed with HTTP 403/, + ); + }); + + it('rejects an empty GitHub installation token', async () => { + await assert.rejects( + createInstallationToken(CONFIG, { + execute: () => '+/8=\n', + fetch: async () => ({ + ok: true, + json: async () => ({ token: '' }), + }), + }), + /GitHub returned an empty installation token/, + ); + }); +}); diff --git a/.github/workflows/devflow-pr-review.yml b/.github/workflows/devflow-pr-review.yml index 5e27d4ac82dc..59be07ae4397 100644 --- a/.github/workflows/devflow-pr-review.yml +++ b/.github/workflows/devflow-pr-review.yml @@ -15,8 +15,8 @@ on: permissions: contents: read - issues: write - pull-requests: write + copilot-requests: write + id-token: write concurrency: group: devflow-pr-review-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} @@ -32,10 +32,35 @@ env: jobs: review: runs-on: ubuntu-latest + environment: github-app-auth timeout-minutes: 60 if: ${{ github.event_name != 'pull_request_target' || !github.event.pull_request.draft }} steps: + - name: Checkout GitHub automation + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }} + sparse-checkout: .github/actions/github-app-token + fetch-depth: 1 + persist-credentials: false + + - name: Get GitHub automation token + id: github-auth + uses: ./.github/actions/github-app-token + with: + mode: ${{ vars.GH_APP_AUTH_MODE }} + azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }} + azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }} + key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }} + key-name: ${{ secrets.GH_APP_KEY_NAME }} + github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }} + github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }} + repository: ${{ github.repository }} + permission-profile: devflow + fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} + - name: Resolve PR metadata id: pr shell: bash @@ -102,8 +127,8 @@ jobs: id: spam working-directory: ${{ env.DEVFLOW_PATH }} env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }} + DEVFLOW_GITHUB_TOKEN: ${{ steps.github-auth.outputs.token }} + GITHUB_TOKEN: ${{ github.token }} SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }} AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }} PR_REPO: ${{ steps.pr.outputs.repo }} @@ -113,6 +138,7 @@ jobs: --repo "$PR_REPO" \ --pr-number "$PR_NUMBER" \ --repo-path "${TARGET_REPO_PATH}" \ + --github-token "$DEVFLOW_GITHUB_TOKEN" \ --apply-labels - name: Stop after spam gate @@ -128,8 +154,8 @@ jobs: id: review working-directory: ${{ env.DEVFLOW_PATH }} env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }} + DEVFLOW_GITHUB_TOKEN: ${{ steps.github-auth.outputs.token }} + GITHUB_TOKEN: ${{ github.token }} SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }} AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }} PR_URL: ${{ steps.pr.outputs.pr_url }} @@ -137,4 +163,5 @@ jobs: uv run python scripts/trigger_pr_review.py \ --pr-url "$PR_URL" \ --github-username "$GITHUB_ACTOR" \ + --github-token "$DEVFLOW_GITHUB_TOKEN" \ --no-require-comment-selection diff --git a/.github/workflows/github-automation-tests.yml b/.github/workflows/github-automation-tests.yml new file mode 100644 index 000000000000..d96f9180ff41 --- /dev/null +++ b/.github/workflows/github-automation-tests.yml @@ -0,0 +1,37 @@ +name: GitHub automation tests + +on: + pull_request: + paths: + - ".github/actions/**" + - ".github/tests/**" + - ".github/workflows/devflow-pr-review.yml" + - ".github/workflows/github-automation-tests.yml" + - ".github/workflows/label-issues.yml" + - ".github/workflows/label-pr.yml" + push: + branches: + - main + paths: + - ".github/actions/**" + - ".github/tests/**" + - ".github/workflows/devflow-pr-review.yml" + - ".github/workflows/github-automation-tests.yml" + - ".github/workflows/label-issues.yml" + - ".github/workflows/label-pr.yml" + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + + - name: Run JavaScript tests + run: node --test .github/tests/*.js diff --git a/.github/workflows/label-issues.yml b/.github/workflows/label-issues.yml index 94d763b63fbc..30681c79005f 100644 --- a/.github/workflows/label-issues.yml +++ b/.github/workflows/label-issues.yml @@ -10,12 +10,37 @@ jobs: name: "Issue: add labels" if: ${{ github.event.action == 'opened' || github.event.action == 'reopened' }} runs-on: ubuntu-latest + environment: github-app-auth permissions: - issues: write + contents: read + id-token: write steps: + - name: Checkout GitHub automation + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: .github/actions/github-app-token + fetch-depth: 1 + persist-credentials: false + + - name: Get GitHub automation token + id: github-auth + uses: ./.github/actions/github-app-token + with: + mode: ${{ vars.GH_APP_AUTH_MODE }} + azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }} + azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }} + key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }} + key-name: ${{ secrets.GH_APP_KEY_NAME }} + github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }} + github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }} + repository: ${{ github.repository }} + permission-profile: issues + fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: - github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} + github-token: ${{ steps.github-auth.outputs.token }} script: | // Get the issue body and title const body = context.payload.issue.body diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml index b50626e8bfd9..f751f93a56ba 100644 --- a/.github/workflows/label-pr.yml +++ b/.github/workflows/label-pr.yml @@ -11,11 +11,36 @@ on: [pull_request_target] jobs: add_label: runs-on: ubuntu-latest + environment: github-app-auth permissions: contents: read - pull-requests: write + id-token: write steps: + - name: Checkout GitHub automation + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + sparse-checkout: .github/actions/github-app-token + fetch-depth: 1 + persist-credentials: false + + - name: Get GitHub automation token + id: github-auth + uses: ./.github/actions/github-app-token + with: + mode: ${{ vars.GH_APP_AUTH_MODE }} + azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }} + azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }} + key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }} + key-name: ${{ secrets.GH_APP_KEY_NAME }} + github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }} + github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }} + repository: ${{ github.repository }} + permission-profile: pull-requests + fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} + - uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0 with: - repo-token: "${{ secrets.GH_ACTIONS_PR_WRITE }}" + repo-token: ${{ steps.github-auth.outputs.token }}