# Step template for creating a GitHub App token via Azure Key Vault. # This template can be used in release jobs where checkout is not available. # The CLI script from microsoft/create-github-app-token-via-key-vault is inlined below. # # The token is created and stored in an Azure Pipelines variable (default: GH_TOKEN). # A revocation step runs at the end with condition: always(). # To use the token, add your steps between this template's create and revoke steps # by setting insertSteps. parameters: - name: azureSubscription type: string default: 'TypeScript Public CI' - name: owner type: string default: 'microsoft' - name: repositories type: string - name: permissions type: string - name: tokenVariable type: string default: 'GH_TOKEN' - name: insertSteps type: stepList default: [] steps: - task: AzureCLI@2 displayName: Create GitHub App token inputs: azureSubscription: ${{ parameters.azureSubscription }} scriptType: bash scriptLocation: inlineScript inlineScript: | cat << 'GITHUB_APP_TOKEN_CLI_EOF' > /tmp/create-github-app-token.cjs "use strict"; // src/api.ts var defaultRefreshWindowMs = 5 * 60 * 1e3; var defaultGitHubApiUrl = "https://api.github.com"; var transientRetryCount = 3; var GitHubRequestError = class extends Error { status; constructor(message, status) { super(message); this.status = status; } }; function assertValue(value, message) { if (!value) { throw new Error(message); } return value; } function base64url(value) { return Buffer.from(value).toString("base64url"); } async function sleep(ms) { await new Promise((resolve) => setTimeout(resolve, ms)); } function isRetryableError(error) { return error instanceof GitHubRequestError ? error.status >= 500 : error instanceof TypeError; } async function retryTransient(operation) { for (let attempt = 0; ; attempt++) { try { return await operation(); } catch (error) { if (attempt >= transientRetryCount || !isRetryableError(error)) { throw error; } await sleep(2 ** attempt * 1e3); } } } function splitRepositoryNames(repositories) { if (Array.isArray(repositories)) { return repositories.map((repo) => `${repo}`.trim()).filter(Boolean); } if (typeof repositories === "string") { return repositories.split(/[,\n]/).map((repo) => repo.trim()).filter(Boolean); } return []; } function stableObject(value) { if (!value || typeof value !== "object" || Array.isArray(value)) { return value; } return Object.fromEntries( Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, stableObject(entry)]) ); } function githubHeaders(token, json = false) { return { "Accept": "application/vnd.github+json", "Authorization": `Bearer ${token}`, ...json ? { "Content-Type": "application/json" } : {}, "X-GitHub-Api-Version": "2022-11-28" }; } function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function requiredIntegerProperty(value, property, failureMessage) { const propertyValue = isRecord(value) ? value[property] : void 0; if (typeof propertyValue !== "number" || !Number.isInteger(propertyValue)) { throw new Error(failureMessage); } return propertyValue; } function requiredStringProperty(value, property, failureMessage) { const propertyValue = isRecord(value) ? value[property] : void 0; if (typeof propertyValue !== "string" || !propertyValue) { throw new Error(failureMessage); } return propertyValue; } function validatePermissionName(key) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { throw new Error(`Invalid permission name: ${key}`); } } function validatePermissionLevel(key, level) { if (level !== "read" && level !== "write" && level !== "admin") { throw new Error(`Invalid permission level for ${key}: ${level}`); } return level; } function validatePermissions(value) { if (value === void 0) { return void 0; } if (!isRecord(value)) { throw new Error("permissions must be an object"); } const permissions = {}; for (const [key, level] of Object.entries(value)) { validatePermissionName(key); permissions[key] = validatePermissionLevel(key, level); } return Object.keys(permissions).length === 0 ? void 0 : permissions; } async function requestJson(url, init, failureMessage) { const response = await fetch(url, init); const body = await response.text(); if (!response.ok) { throw new GitHubRequestError( `${failureMessage}: ${response.status} ${response.statusText}: ${body}`, response.status ); } try { return JSON.parse(body); } catch { throw new Error(`${failureMessage}: GitHub returned invalid JSON`); } } async function requestNoContent(url, init, failureMessage) { const response = await fetch(url, init); if (!response.ok) { const body = await response.text(); throw new GitHubRequestError( `${failureMessage}: ${response.status} ${response.statusText}: ${body}`, response.status ); } } function parseRepositoryInput(input) { const parts = input.split("/"); if (parts.length === 1 && parts[0]) { return { input, name: parts[0] }; } if (parts.length === 2 && parts[0] && parts[1]) { return { input, owner: parts[0], name: parts[1] }; } throw new Error(`Invalid repository '${input}'. Expected 'repository' or 'owner/repository'.`); } function normalizeRepositoryTarget(owner, repositories, defaultOwner) { const parsedRepositories = repositories.map(parseRepositoryInput); const repositoryOwner = parsedRepositories.find((repository) => repository.owner)?.owner; const parsedOwner = owner || defaultOwner || repositoryOwner; if (!parsedOwner) { throw new Error("owner is required when repositories are provided"); } const mismatchedRepository = parsedRepositories.find( (repository) => repository.owner && repository.owner.toLowerCase() !== parsedOwner.toLowerCase() ); if (mismatchedRepository) { throw new Error( `Repository '${mismatchedRepository.input}' includes owner '${mismatchedRepository.owner}', which does not match the resolved owner '${parsedOwner}'.` ); } return { owner: parsedOwner, repositories: parsedRepositories.map((repository) => repository.name) }; } function resolveInstallationTarget(options, defaultOwner) { const repositories = splitRepositoryNames(options.repositories ?? options.repositoryNames); if (options.enterprise) { if (options.owner || repositories.length > 0) { throw new Error("Cannot use 'enterprise' with 'owner' or 'repositories'"); } return { type: "enterprise", enterprise: options.enterprise }; } const owner = assertValue(options.owner ?? defaultOwner, "owner is required to discover installation ID"); if (repositories.length === 0) { return { type: "owner", owner }; } return { type: "repository", owner, repositories }; } function createGitHubAppAuth(options) { assertValue(options.appClientId, "appClientId is required"); assertValue(options.signer, "signer is required"); const appClientId = options.appClientId; const signer = options.signer; const defaultOwner = options.defaultOwner; const refreshWindowMs = options.refreshWindowMs ?? defaultRefreshWindowMs; const githubApiUrl = options.githubApiUrl ?? defaultGitHubApiUrl; const installationCache = /* @__PURE__ */ new Map(); const tokenCache = /* @__PURE__ */ new Map(); async function createJwt() { const now = Math.floor(Date.now() / 1e3); const iat = now - 60; const exp = now + 9 * 60; const header = base64url(JSON.stringify({ typ: "JWT", alg: "RS256" })); const payload = base64url(JSON.stringify({ iat, exp, iss: appClientId })); const signingInput = `${header}.${payload}`; const signature = await signer(signingInput); return `${signingInput}.${signature}`; } async function discoverInstallation(target) { const cacheKey = JSON.stringify(target); const cached = installationCache.get(cacheKey); if (cached !== void 0) { return cached; } const jwt = await createJwt(); let installation; switch (target.type) { case "enterprise": installation = await requestJson( `${githubApiUrl}/enterprises/${target.enterprise}/installation`, { headers: githubHeaders(jwt) }, "Could not discover GitHub App installation ID" ); break; case "owner": try { installation = await requestJson( `${githubApiUrl}/orgs/${target.owner}/installation`, { headers: githubHeaders(jwt) }, "Could not discover GitHub App installation ID" ); } catch (error) { if (!(error instanceof GitHubRequestError) || error.status !== 404) { throw error; } installation = await requestJson( `${githubApiUrl}/users/${target.owner}/installation`, { headers: githubHeaders(jwt) }, "Could not discover GitHub App installation ID" ); } break; case "repository": installation = await requestJson( `${githubApiUrl}/repos/${target.owner}/${assertValue(target.repositories[0], "repository is required")}/installation`, { headers: githubHeaders(jwt) }, "Could not discover GitHub App installation ID" ); break; } const result = { id: requiredIntegerProperty(installation, "id", "GitHub did not return an installation ID"), appSlug: requiredStringProperty(installation, "app_slug", "GitHub did not return an App slug") }; installationCache.set(cacheKey, result); return result; } async function getInstallationToken(options2) { const target = resolveInstallationTarget(options2, defaultOwner); const permissions = validatePermissions(options2.permissions); return retryTransient(async () => { const installation = await discoverInstallation(target); const repositories = target.type === "repository" ? target.repositories : []; const cacheKey = JSON.stringify({ installationId: installation.id, repositories: [...repositories].sort(), permissions: stableObject(permissions) }); const cached = tokenCache.get(cacheKey); if (cached && Date.now() < new Date(cached.expiresAt).getTime() - refreshWindowMs) { return cached; } const jwt = await createJwt(); const body = { ...repositories.length > 0 ? { repositories } : {}, ...permissions ? { permissions } : {} }; const token = await requestJson( `${githubApiUrl}/app/installations/${installation.id}/access_tokens`, { method: "POST", headers: githubHeaders(jwt, true), body: JSON.stringify(body) }, "Could not create GitHub App installation token" ); const result = { token: requiredStringProperty(token, "token", "GitHub did not return an installation token"), expiresAt: requiredStringProperty( token, "expires_at", "GitHub did not return an installation token expiration" ), installationId: installation.id, appSlug: installation.appSlug, repositories, permissions: isRecord(token) && isRecord(token["permissions"]) ? token["permissions"] : permissions ?? {} }; tokenCache.set(cacheKey, result); return result; }); } async function getToken(options2) { return (await getInstallationToken(options2)).token; } async function revokeToken(token) { await requestNoContent( `${githubApiUrl}/installation/token`, { method: "DELETE", headers: githubHeaders(token) }, "Could not revoke GitHub App installation token" ); } return { getInstallationToken, getToken, revokeToken }; } // src/azureCliSigner.ts var import_node_child_process = require("node:child_process"); var import_node_crypto = require("node:crypto"); var cachedAzCommand; function isRecord2(value) { return typeof value === "object" && value !== null; } function base64ToBase64url(value) { return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } function commandExists(command) { try { (0, import_node_child_process.execFileSync)("where.exe", [command], { encoding: "utf8", stdio: ["ignore", "ignore", "ignore"] }); return true; } catch { return false; } } function azCommand() { if (cachedAzCommand) { return cachedAzCommand; } if (process.platform !== "win32") { cachedAzCommand = { command: "az", argsPrefix: [] }; return cachedAzCommand; } for (const command of ["az.exe", "az.cmd", "az.bat", "az"]) { if (commandExists(command)) { cachedAzCommand = command.endsWith(".exe") ? { command, argsPrefix: [] } : { command: process.env["ComSpec"] || "cmd.exe", argsPrefix: ["/d", "/s", "/c", command] }; return cachedAzCommand; } } throw new Error("Azure CLI (`az`) was not found on PATH"); } function signDigest(keyId, digest) { const { command, argsPrefix } = azCommand(); try { return (0, import_node_child_process.execFileSync)(command, [ ...argsPrefix, "keyvault", "key", "sign", "--id", keyId, "--algorithm", "RS256", "--digest", digest, "--query", "signature", "--output", "tsv", "--only-show-errors" ], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); } catch (error) { if (isRecord2(error)) { if (error["code"] === "ENOENT") { throw new Error("Azure CLI (`az`) was not found on PATH"); } const errorStderr = error["stderr"]; const stderr = typeof errorStderr === "string" ? errorStderr.trim() : ""; if (typeof error["status"] === "number") { throw new Error( `Azure Key Vault signing failed with exit code ${error["status"]}${stderr ? `: ${stderr}` : ""}` ); } } throw new Error("Azure Key Vault signing failed"); } } function createAzureCliKeyVaultSigner(keyId) { if (!keyId) { throw new Error("keyId is required"); } return async (signingInput) => { const digest = (0, import_node_crypto.createHash)("sha256").update(signingInput).digest("base64"); const signature = signDigest(keyId, digest); if (!signature) { throw new Error("Azure Key Vault did not return a signature"); } return base64ToBase64url(signature); }; } // src/proxy.ts var proxyEnvironmentKeys = [ "https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY" ]; function proxyEnvironmentConfigured() { return proxyEnvironmentKeys.some((key) => process.env[key]); } function nativeProxySupportEnabled() { return process.env["NODE_USE_ENV_PROXY"] === "1"; } function ensureNativeProxySupport() { if (!proxyEnvironmentConfigured() || nativeProxySupportEnabled()) { return; } throw new Error( "A proxy environment variable is set, but Node.js native proxy support is not enabled. Set NODE_USE_ENV_PROXY=1 before running this tool." ); } // src/cli.ts function requiredEnv(name) { const value = process.env[name]; if (!value) { throw new Error(`${name} must be set`); } return value; } function getAppClientId() { const appClientId = process.env["APP_CLIENT_ID"]; if (!appClientId) { throw new Error("APP_CLIENT_ID must be set"); } return appClientId; } function validatePermissionName2(key) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { throw new Error(`Invalid permission name: ${key}`); } } function validatePermissionLevel2(key, level) { if (level !== "read" && level !== "write" && level !== "admin") { throw new Error(`Invalid permission level for ${key}: ${level}`); } return level; } function parsePermissions(value) { if (!value) { return void 0; } const permissions = {}; for (const entry of splitRepositoryNames(value)) { const parts = entry.split(":"); if (parts.length !== 2) { throw new Error(`Permission entry must include an explicit level: ${entry}`); } const key = parts[0]?.trim(); const rawLevel = parts[1]?.trim(); if (!key) { throw new Error(`Permission entry must include a permission name: ${entry}`); } validatePermissionName2(key); if (Object.hasOwn(permissions, key)) { throw new Error(`Duplicate permission: ${key}`); } permissions[key] = validatePermissionLevel2(key, rawLevel); } return Object.keys(permissions).length === 0 ? void 0 : permissions; } function validateVariableName(name, envName) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { throw new Error(`${envName} must be an environment-style variable name`); } } function parseOutputMode(value) { const output = (value || "stdout").trim().toLowerCase(); if (output === "azure" || output === "azure-pipelines" || output === "stdout") { return output; } throw new Error('OUTPUT must be "azure", "azure-pipelines", or "stdout"'); } function getTokenOptions() { const enterprise = process.env["ENTERPRISE"]; const owner = process.env["OWNER"]; const repositories = splitRepositoryNames(process.env["REPOSITORIES"]); const permissions = parsePermissions(process.env["PERMISSIONS"]); if (enterprise) { if (owner || repositories.length > 0) { throw new Error("Cannot use ENTERPRISE with OWNER or REPOSITORIES"); } return { enterprise, permissions }; } if (repositories.length > 0) { return { ...normalizeRepositoryTarget(owner, repositories, void 0), permissions }; } if (owner) { return { owner, permissions }; } throw new Error("OWNER, REPOSITORIES, or ENTERPRISE must be set"); } function writeAzurePipelinesOutput(installationToken) { const variableName = requiredEnv("AZURE_TOKEN_VARIABLE"); validateVariableName(variableName, "AZURE_TOKEN_VARIABLE"); process.stdout.write(`##vso[task.setvariable variable=${variableName};isSecret=true]${installationToken.token} `); } function writeOutput(installationToken, output) { switch (output) { case "azure": case "azure-pipelines": writeAzurePipelinesOutput(installationToken); break; case "stdout": process.stdout.write(`${installationToken.token} `); break; } } function reportError(error) { const message = error instanceof Error ? error.message : String(error); console.error(`error: ${message}`); } async function main() { ensureNativeProxySupport(); const githubApiUrl = process.env["GITHUB_API_URL"] || "https://api.github.com"; const revokeTokenValue = process.env["REVOKE_TOKEN"]; if (revokeTokenValue) { const response = await fetch(`${githubApiUrl}/installation/token`, { method: "DELETE", headers: { "Accept": "application/vnd.github+json", "Authorization": `Bearer ${revokeTokenValue}`, "X-GitHub-Api-Version": "2022-11-28" } }); if (!response.ok) { const body = await response.text(); throw new Error(`Could not revoke token: ${response.status} ${response.statusText}: ${body}`); } return; } const githubAuth = createGitHubAppAuth({ appClientId: getAppClientId(), signer: createAzureCliKeyVaultSigner(requiredEnv("KEY_ID")), githubApiUrl }); const installationToken = await githubAuth.getInstallationToken(getTokenOptions()); writeOutput(installationToken, parseOutputMode(process.env["OUTPUT"])); } void main().catch((error) => { reportError(error); process.exitCode = 1; }); GITHUB_APP_TOKEN_CLI_EOF node /tmp/create-github-app-token.cjs env: APP_CLIENT_ID: $(TYPESCRIPT_AUTOMATION_GITHUB_APP_CLIENT_ID) KEY_ID: $(TYPESCRIPT_AUTOMATION_GITHUB_APP_KEY_ID) OWNER: ${{ parameters.owner }} REPOSITORIES: ${{ parameters.repositories }} PERMISSIONS: ${{ parameters.permissions }} OUTPUT: azure-pipelines AZURE_TOKEN_VARIABLE: ${{ parameters.tokenVariable }} - ${{ each step in parameters.insertSteps }}: - ${{ step }} - task: Bash@3 displayName: Revoke GitHub App token condition: always() continueOnError: true inputs: targetType: inline script: node /tmp/create-github-app-token.cjs env: REVOKE_TOKEN: $(${{ parameters.tokenVariable }})