mirror of
https://github.com/microsoft/TypeScript.git
synced 2026-06-13 11:46:08 -05:00
615 lines
24 KiB
YAML
615 lines
24 KiB
YAML
# 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 }})
|